Add unit tests and knowledge files for BC domain context

- Introduced unit tests for the bc-domain-context implementation, covering various scenarios including filtering by application area, technology mismatches, layer precedence, and conditional applicability.
- Added knowledge files related to finance, including topics such as Chart of Accounts, Codeunit 12, Dimension Management, and VAT on prepayment chains, among others.
- Each knowledge file includes structured metadata and best practices to enhance the domain knowledge available for Business Central tasks.
This commit is contained in:
Jesper Schulz-Wedde 2026-04-22 11:33:47 +02:00
parent 0142e1e0de
commit 7fbb121c24
18 changed files with 1170 additions and 1 deletions

373
.github/scripts/bc_domain_context.py vendored Normal file
View file

@ -0,0 +1,373 @@
#!/usr/bin/env python3
"""
Reference implementation of the bc-domain-context action skill.
Executes the skill's Source -> Relevance -> Worklist -> Action pipeline against
a BCQuality repository root and returns a findings-report dict conforming to
the DO output contract.
This module is the *spec by example* for consumers that reimplement the skill
in other languages (for example, the Triage agent's Node.js client). Tests in
.github/scripts/tests/test_bc_domain_context.py exercise this implementation
against fixture knowledge trees.
Usage (from Python):
from bc_domain_context import run_bc_domain_context
report = run_bc_domain_context(repo_root, task_context)
Skill behaviour is defined in microsoft/skills/bc-domain-context.md; READ's
frontmatter-matching semantics live in skills/read.md.
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
try:
import yaml
except ImportError:
sys.stderr.write("ERROR: PyYAML is required. Install with: pip install pyyaml\n")
sys.exit(2)
LAYERS = ("custom", "microsoft", "community") # highest precedence first
SKILL_ID = "bc-domain-context"
SKILL_VERSION = 1
@dataclass
class KnowledgeFile:
path: str # repo-relative, forward slashes
layer: str # "microsoft" | "community" | "custom"
domain: str # folder name
slug: str # filename stem
frontmatter: dict[str, Any]
title: str # first H1 in body, or slug if absent
description: str # prose after "## Description", trimmed
# --- Frontmatter + body parsing ---------------------------------------------
def _parse_markdown(text: str) -> tuple[dict[str, Any] | None, str]:
lines = text.splitlines()
if not lines or lines[0].rstrip() != "---":
return None, text
for i in range(1, len(lines)):
if lines[i].rstrip() == "---":
try:
fm = yaml.safe_load("\n".join(lines[1:i])) or {}
except yaml.YAMLError:
return None, text
if not isinstance(fm, dict):
return None, text
return fm, "\n".join(lines[i + 1:])
return None, text
def _extract_title(body: str, fallback: str) -> str:
for line in body.splitlines():
m = re.match(r"^#\s+(.+?)\s*$", line)
if m:
return m.group(1).strip()
return fallback
def _extract_description(body: str) -> str:
lines = body.splitlines()
for i, line in enumerate(lines):
if re.match(r"^##\s+Description\s*$", line):
buf: list[str] = []
for j in range(i + 1, len(lines)):
if re.match(r"^##\s+", lines[j]):
break
buf.append(lines[j])
return "\n".join(buf).strip()
return ""
# --- bc-version expansion (matches validate_frontmatter.expand_bc_version) --
_RANGE = re.compile(r"^(\d+)\.\.(\d+)$")
def _expand_bc_version(value: Any) -> list[int] | None:
if not isinstance(value, list) or not value:
return None
if all(isinstance(v, int) and not isinstance(v, bool) and v > 0 for v in value):
return sorted(set(value))
if len(value) == 1 and isinstance(value[0], str):
m = _RANGE.match(value[0].strip())
if m:
start, end = int(m.group(1)), int(m.group(2))
if start <= end:
return list(range(start, end + 1))
return None
# --- READ's frontmatter matching rules --------------------------------------
def _matches(
kf: KnowledgeFile, task_context: dict[str, Any]
) -> tuple[bool, list[str]]:
"""Return (applicable, unknown_dimensions) per READ's semantics.
A file is applicable when every rule matches. A rule is "unknown" when the
task context omits a dimension and the file does not declare a universal
sentinel for it; unknown rules do not disqualify the file but force a
medium-confidence ceiling on derived findings.
"""
unknown: list[str] = []
fm = kf.frontmatter
# bc-version
file_versions = _expand_bc_version(fm.get("bc-version"))
if file_versions is None:
return False, unknown
task_version = task_context.get("bc-version")
if task_version is None:
unknown.append("bc-version")
elif task_version not in file_versions:
return False, unknown
# technologies (no sentinel)
file_techs = set(fm.get("technologies") or [])
task_techs = set(task_context.get("technologies") or [])
if not task_techs:
unknown.append("technologies")
elif not (file_techs & task_techs):
return False, unknown
# countries (sentinel: w1)
file_countries = set(fm.get("countries") or [])
task_countries = set(task_context.get("countries") or [])
if "w1" in file_countries:
pass
elif not task_countries:
unknown.append("countries")
elif not (file_countries & task_countries):
return False, unknown
# application-area (sentinel: all)
file_areas = set(fm.get("application-area") or [])
task_areas = set(task_context.get("application-area") or [])
if "all" in file_areas:
pass
elif not task_areas or "all" in task_areas:
# Empty or [all] in task means "any area"; file-level specific area
# still matches against any-area when sourced from the area folder.
pass
elif not (file_areas & task_areas):
return False, unknown
return True, unknown
# --- Knowledge corpus load --------------------------------------------------
def _load_knowledge_file(root: Path, path: Path) -> KnowledgeFile | None:
rel = path.relative_to(root).as_posix()
parts = rel.split("/")
if len(parts) != 4 or parts[1] != "knowledge":
return None
layer, _, domain, filename = parts
slug = Path(filename).stem
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return None
fm, body = _parse_markdown(text)
if fm is None:
return None
return KnowledgeFile(
path=rel,
layer=layer,
domain=domain,
slug=slug,
frontmatter=fm,
title=_extract_title(body, slug),
description=_extract_description(body),
)
def _load_corpus(
root: Path, enabled_layers: Iterable[str], areas: Iterable[str] | None
) -> list[KnowledgeFile]:
"""Walk <layer>/knowledge/<area>/*.md for enabled layers.
If `areas` is None or contains "all", walk every area folder. Otherwise,
walk only the named area folders.
"""
enabled = set(enabled_layers) & set(LAYERS)
area_set = None
if areas is not None:
areas_list = list(areas)
if areas_list and "all" not in areas_list:
area_set = set(areas_list)
corpus: list[KnowledgeFile] = []
for layer in enabled:
knowledge_root = root / layer / "knowledge"
if not knowledge_root.is_dir():
continue
for domain_dir in knowledge_root.iterdir():
if not domain_dir.is_dir():
continue
if area_set is not None and domain_dir.name not in area_set:
continue
for md_path in domain_dir.glob("*.md"):
kf = _load_knowledge_file(root, md_path)
if kf is not None:
corpus.append(kf)
return corpus
# --- Layer precedence -------------------------------------------------------
def _resolve_precedence(
files: list[KnowledgeFile],
) -> tuple[list[KnowledgeFile], list[dict[str, Any]]]:
"""Keep the highest-precedence file per (domain, slug). Return (kept, suppressed).
suppressed entries have shape { "reference": { "path": ... }, "reason": "layer-precedence" }.
"""
precedence = {layer: i for i, layer in enumerate(LAYERS)} # lower index = higher precedence
groups: dict[tuple[str, str], list[KnowledgeFile]] = {}
for f in files:
groups.setdefault((f.domain, f.slug), []).append(f)
kept: list[KnowledgeFile] = []
suppressed: list[dict[str, Any]] = []
for group in groups.values():
group.sort(key=lambda f: precedence.get(f.layer, 99))
kept.append(group[0])
for loser in group[1:]:
suppressed.append({
"reference": {"path": loser.path},
"reason": "layer-precedence",
})
return kept, suppressed
# --- Worklist narrowing -----------------------------------------------------
_GOAL_PREFIX = re.compile(r"^bc-domain-context(?:\s+for\s+[a-z0-9,\- ]+)?\s*", re.IGNORECASE)
_TOKEN = re.compile(r"[a-z0-9]+")
def _extract_goal_tokens(goal: str) -> list[str]:
"""Strip the 'bc-domain-context for <area>' prefix and return significant tokens."""
if not goal:
return []
stripped = _GOAL_PREFIX.sub("", goal).strip()
if not stripped:
return []
return [t for t in _TOKEN.findall(stripped.lower()) if len(t) >= 3]
def _score(kf: KnowledgeFile, tokens: list[str]) -> int:
if not tokens:
return 0
kw = {k.lower() for k in (kf.frontmatter.get("keywords") or [])}
text = f"{kf.slug} {kf.title} {kf.description}".lower()
score = 0
for t in tokens:
if t in kw:
score += 3
if t in text:
score += 1
return score
def _narrow(
kept: list[KnowledgeFile], task_context: dict[str, Any], max_top: int = 15
) -> list[KnowledgeFile]:
tokens = _extract_goal_tokens(task_context.get("goal") or "")
if not tokens:
return kept
scored = [(kf, _score(kf, tokens)) for kf in kept]
scored.sort(key=lambda p: (-p[1], p[0].path))
# Keep only files with positive score; fall back to full set if none score.
positive = [kf for kf, s in scored if s > 0]
if not positive:
return kept
return positive[:max_top]
# --- Message construction ---------------------------------------------------
def _message(kf: KnowledgeFile, unknown: list[str]) -> str:
first_sentences = re.split(r"(?<=[.!?])\s+", kf.description, maxsplit=3)
lead = " ".join(first_sentences[:3]).strip() if first_sentences else ""
msg = f"{kf.title}. {lead}" if lead else kf.title
if unknown:
msg += f" (conditional on: {', '.join(sorted(unknown))})"
return msg
# --- Main entrypoint --------------------------------------------------------
def run_bc_domain_context(
root: Path | str, task_context: dict[str, Any]
) -> dict[str, Any]:
"""Execute the skill against the repository at `root`.
Returns a findings-report dict conforming to the DO output contract.
"""
root = Path(root)
enabled = task_context.get("enabled-layers") or list(LAYERS)
areas = task_context.get("application-area")
corpus = _load_corpus(root, enabled, areas)
if not corpus:
return _report("no-knowledge", [], [])
applicable: list[tuple[KnowledgeFile, list[str]]] = []
for kf in corpus:
ok, unknown = _matches(kf, task_context)
if ok:
applicable.append((kf, unknown))
if not applicable:
return _report("not-applicable", [], [])
kept, suppressed = _resolve_precedence([kf for kf, _ in applicable])
kept_set = {kf.path for kf in kept}
unknown_by_path = {kf.path: u for kf, u in applicable if kf.path in kept_set}
worklist = _narrow(kept, task_context)
findings: list[dict[str, Any]] = []
for kf in worklist:
unknown = unknown_by_path.get(kf.path, [])
findings.append({
"id": kf.path,
"severity": "info",
"message": _message(kf, unknown),
"references": [{"path": kf.path}],
"confidence": "medium" if unknown else "high",
})
return _report("completed", findings, suppressed)
def _report(
outcome: str, findings: list[dict[str, Any]], suppressed: list[dict[str, Any]]
) -> dict[str, Any]:
return {
"skill": {"id": SKILL_ID, "version": SKILL_VERSION},
"outcome": outcome,
"summary": {
"counts": {
"blocker": 0,
"major": 0,
"minor": 0,
"info": len(findings),
},
"coverage": {
"worklist-size": len(findings),
"items-evaluated": len(findings),
},
},
"findings": findings,
"suppressed": suppressed,
}

View file

@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""
Tests for the bc-domain-context reference implementation.
Run with: python -m unittest .github.scripts.tests.test_bc_domain_context
Or: cd .github/scripts/tests && python -m unittest test_bc_domain_context
The tests build fixture knowledge trees in tempfile directories and exercise
the skill's Source / Relevance / Worklist / Action pipeline end to end.
"""
from __future__ import annotations
import os
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path
# Add the scripts directory (parent of tests/) to sys.path so we can import
# bc_domain_context.py.
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_SCRIPTS_DIR))
from bc_domain_context import run_bc_domain_context # noqa: E402
def _write(
root: Path,
layer: str,
domain: str,
slug: str,
*,
bc_version: str = "[26..28]",
technologies: str = "[al]",
countries: str = "[w1]",
application_area: str | None = None,
keywords: str = "[sample]",
domain_value: str | None = None,
description: str = "Short description. Another sentence.",
title: str | None = None,
) -> Path:
"""Write a fixture knowledge file and return its path."""
if application_area is None:
application_area = f"[{domain}]"
if domain_value is None:
domain_value = domain
if title is None:
title = slug.replace("-", " ").capitalize()
target = root / layer / "knowledge" / domain / f"{slug}.md"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(
textwrap.dedent(
f"""\
---
bc-version: {bc_version}
domain: {domain_value}
keywords: {keywords}
technologies: {technologies}
countries: {countries}
application-area: {application_area}
---
# {title}
## Description
{description}
"""
),
encoding="utf-8",
)
return target
class BcDomainContextTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.root = Path(self._tmp.name)
self.addCleanup(self._tmp.cleanup)
# --- Core filtering ---
def test_filter_by_area_returns_only_requested_area(self):
_write(self.root, "microsoft", "finance", "chart-of-accounts")
_write(self.root, "microsoft", "finance", "general-ledger-entries")
_write(self.root, "microsoft", "finance", "dimensions")
_write(self.root, "microsoft", "sales", "order-to-cash")
_write(self.root, "microsoft", "sales", "pricing")
report = run_bc_domain_context(self.root, {
"application-area": ["finance"],
"technologies": ["al"],
"bc-version": 28,
"countries": ["w1"],
})
self.assertEqual(report["outcome"], "completed")
paths = sorted(f["id"] for f in report["findings"])
self.assertEqual(paths, [
"microsoft/knowledge/finance/chart-of-accounts.md",
"microsoft/knowledge/finance/dimensions.md",
"microsoft/knowledge/finance/general-ledger-entries.md",
])
for finding in report["findings"]:
self.assertEqual(finding["severity"], "info")
self.assertEqual(finding["confidence"], "high")
def test_application_area_all_returns_union(self):
_write(self.root, "microsoft", "finance", "chart-of-accounts")
_write(self.root, "microsoft", "sales", "order-to-cash")
_write(self.root, "microsoft", "manufacturing", "bom")
report = run_bc_domain_context(self.root, {
"application-area": ["all"],
"technologies": ["al"],
"bc-version": 28,
"countries": ["w1"],
})
self.assertEqual(report["outcome"], "completed")
self.assertEqual(len(report["findings"]), 3)
paths = {f["id"] for f in report["findings"]}
self.assertIn("microsoft/knowledge/finance/chart-of-accounts.md", paths)
self.assertIn("microsoft/knowledge/sales/order-to-cash.md", paths)
self.assertIn("microsoft/knowledge/manufacturing/bom.md", paths)
def test_technologies_mismatch_drops_file(self):
_write(self.root, "microsoft", "finance", "al-concept")
_write(
self.root,
"microsoft",
"finance",
"kql-only-concept",
technologies="[kql]",
)
report = run_bc_domain_context(self.root, {
"application-area": ["finance"],
"technologies": ["al"],
"bc-version": 28,
"countries": ["w1"],
})
paths = {f["id"] for f in report["findings"]}
self.assertIn("microsoft/knowledge/finance/al-concept.md", paths)
self.assertNotIn("microsoft/knowledge/finance/kql-only-concept.md", paths)
def test_no_matching_knowledge_returns_not_applicable(self):
_write(self.root, "microsoft", "finance", "chart-of-accounts")
report = run_bc_domain_context(self.root, {
"application-area": ["manufacturing"],
"technologies": ["al"],
"bc-version": 28,
"countries": ["w1"],
})
self.assertEqual(report["outcome"], "no-knowledge")
self.assertEqual(report["findings"], [])
# --- Layer precedence ---
def test_layer_precedence_microsoft_wins_over_community(self):
_write(self.root, "community", "finance", "vat-on-prepayment")
_write(self.root, "microsoft", "finance", "vat-on-prepayment")
report = run_bc_domain_context(self.root, {
"application-area": ["finance"],
"technologies": ["al"],
"bc-version": 28,
"countries": ["w1"],
})
paths = [f["id"] for f in report["findings"]]
self.assertEqual(
paths, ["microsoft/knowledge/finance/vat-on-prepayment.md"]
)
suppressed_paths = [s["reference"]["path"] for s in report["suppressed"]]
self.assertEqual(
suppressed_paths,
["community/knowledge/finance/vat-on-prepayment.md"],
)
self.assertEqual(report["suppressed"][0]["reason"], "layer-precedence")
def test_layer_precedence_custom_wins_over_microsoft(self):
_write(self.root, "microsoft", "finance", "vat-on-prepayment")
_write(self.root, "custom", "finance", "vat-on-prepayment")
report = run_bc_domain_context(self.root, {
"application-area": ["finance"],
"technologies": ["al"],
"bc-version": 28,
"countries": ["w1"],
})
paths = [f["id"] for f in report["findings"]]
self.assertEqual(
paths, ["custom/knowledge/finance/vat-on-prepayment.md"]
)
# --- Conditional applicability (unknown dimensions) ---
def test_unknown_bc_version_caps_confidence_at_medium(self):
_write(self.root, "microsoft", "finance", "chart-of-accounts")
_write(self.root, "microsoft", "finance", "dimensions")
# bc-version omitted from task-context.
report = run_bc_domain_context(self.root, {
"application-area": ["finance"],
"technologies": ["al"],
"countries": ["w1"],
})
self.assertTrue(report["findings"])
for finding in report["findings"]:
self.assertEqual(finding["confidence"], "medium")
self.assertIn("bc-version", finding["message"])
# --- Goal-directed narrowing ---
def test_goal_tokens_narrow_worklist(self):
_write(
self.root,
"microsoft",
"finance",
"vat-on-prepayment-chains",
keywords="[vat, prepayment, credit-memo]",
)
_write(
self.root,
"microsoft",
"finance",
"dimensions",
keywords="[dimensions, default-priority]",
)
_write(
self.root,
"microsoft",
"finance",
"chart-of-accounts",
keywords="[chart, account]",
)
report = run_bc_domain_context(self.root, {
"goal": "bc-domain-context for finance — VAT wrong on prepayment credit memo",
"application-area": ["finance"],
"technologies": ["al"],
"bc-version": 28,
"countries": ["w1"],
})
paths = [f["id"] for f in report["findings"]]
self.assertIn(
"microsoft/knowledge/finance/vat-on-prepayment-chains.md",
paths,
)
# The highest-scoring file should come first.
self.assertEqual(
paths[0],
"microsoft/knowledge/finance/vat-on-prepayment-chains.md",
)
def test_generic_goal_keeps_full_area(self):
_write(self.root, "microsoft", "finance", "chart-of-accounts")
_write(self.root, "microsoft", "finance", "dimensions")
report = run_bc_domain_context(self.root, {
"goal": "bc-domain-context for finance",
"application-area": ["finance"],
"technologies": ["al"],
"bc-version": 28,
"countries": ["w1"],
})
self.assertEqual(len(report["findings"]), 2)
# --- Layer disabling ---
def test_disabled_layer_is_invisible(self):
_write(self.root, "community", "finance", "only-in-community")
_write(self.root, "microsoft", "finance", "only-in-microsoft")
report = run_bc_domain_context(self.root, {
"application-area": ["finance"],
"technologies": ["al"],
"bc-version": 28,
"countries": ["w1"],
"enabled-layers": ["microsoft"],
})
paths = [f["id"] for f in report["findings"]]
self.assertEqual(paths, ["microsoft/knowledge/finance/only-in-microsoft.md"])
if __name__ == "__main__":
unittest.main()

View file

@ -52,7 +52,7 @@ Every knowledge file is a markdown file with mandatory YAML frontmatter. Files t
```yaml
---
bc-version: [26..28] # BC versions this applies to
bc-version: [26..28] # BC versions this applies to; use [1..99] for universal content
domain: performance # security | performance | ux | telemetry | ...
keywords: [query, filtering, partial] # free-text tags for retrieval
technologies: [al] # al | javascript | powershell | ...
@ -63,6 +63,8 @@ application-area: [all] # finance | manufacturing | jobs | [all]
All six fields are required. The schema is locked — changes require a PR approved by both maintainers.
**`bc-version` convention.** Content that describes version-specific behaviour (a query optimizer change in BC 26, a security API renamed in BC 27) lists the specific major versions or a tight range. Content that describes canonical BC concepts which are stable across every supported release — domain knowledge like `Codeunit 12 (Gen. Jnl.-Post Line)`, `T15 G/L Account`, VAT posting setup, the dimensions model — uses `bc-version: [1..99]`. This signals "applies to every BC version past, present, and future" without forcing an annual bump of every file when a new release ships.
### Sections
Every knowledge file must contain a `## Description` section. The following sections are optional but recommended:

View file

@ -0,0 +1,28 @@
---
bc-version: [1..99]
domain: finance
keywords: [chart-of-accounts, gl-account, account-category, account-subcategory, financial-reports]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# Chart of Accounts
## Description
The Chart of Accounts (table 15 — `G/L Account`) is the backbone of Business Central's financial reporting. Every posting in the system, no matter where it originates, eventually produces G/L Entries against accounts defined here. The chart's structure determines what financial statements look like: accounts carry an Account Category (Assets, Liabilities, Equity, Income, Cost of Goods Sold, Expense) and an Account Subcategory that groups them for statement rows. Financial reports (balance sheet, income statement, trial balance) aggregate entries by these classifications rather than by the raw account numbers.
Because every sub-ledger (customer, vendor, item, fixed asset, bank) ultimately posts to G/L, the chart is the single integration point for all monetary movement. A miscategorised account shifts amounts between sections of the financial statements without producing a posting error — the numbers look fine at the account level and wrong at the statement level.
## Best Practice
Set Account Category and Account Subcategory on every G/L Account — do not leave them blank on new accounts. Run the financial report rebuild after restructuring the chart so the subcategory totals re-calculate against all historical entries.
## Anti Pattern
Using free-text Account Name as the only grouping signal. Reports that aggregate by name are brittle to typos and translation; category/subcategory are the authoritative grouping.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Chart of Accounts & G/L Posting") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Finance\`.

View file

@ -0,0 +1,28 @@
---
bc-version: [1..99]
domain: finance
keywords: [codeunit-12, gen-jnl-post-line, journal-posting, high-risk, ledger-integrity]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# Codeunit 12 (Gen. Jnl.-Post Line)
## Description
Codeunit 12 is the single posting engine for every journal line in Business Central. General journals, payment journals, cash receipt journals, recurring journals, IC journals, and the journal-like intermediaries used by document posting (Sales-Post, Purch.-Post, Invoice Post. Buffer) all funnel through this codeunit to produce G/L Entries, Customer Ledger Entries, Vendor Ledger Entries, Bank Account Ledger Entries, VAT Entries, and Detailed Ledger Entries. The entry numbering, dimension resolution, multi-currency math, and VAT computation for every posted line happen here.
Because every monetary posting passes through codeunit 12, any modification to its behaviour — even a seemingly local change to one sub-procedure — has repository-wide blast radius. A change intended to affect only Purchase posting will also hit Sales, General Journal, Intercompany, bank payments, and every extension that raises integration events on codeunit 12's publishers. Debugging an unexpected posting change across multiple modules frequently traces back here.
## Best Practice
Extend codeunit 12 only through the published integration events (`OnAfterPostGLAcc`, `OnAfterPostCustVendAccount`, etc.). Subscribing is additive and local to the subscriber; forking codeunit 12's body inside an extension loses access to Microsoft's future fixes.
## Anti Pattern
Modifying codeunit 12's core computation in a customisation to fix a reported issue. Every subsequent BC platform upgrade must merge around the modification, and the modification's side effects on other modules are rarely exhaustively tested. Use events.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "High-Risk Areas") on 2026-04-21.

View file

@ -0,0 +1,28 @@
---
bc-version: [1..99]
domain: finance
keywords: [codeunit-408, dimension-management, dimension-merge, high-risk, dimension-set]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# Codeunit 408 (Dimension Management)
## Description
Codeunit 408 is the central broker for every dimension operation in Business Central. It resolves Default Dimensions into concrete Dimension Set Entries, deduplicates sets by Dimension Set ID, merges header/line/master-data defaults during posting, enforces Dimension Combination rules, and owns the API that every other module uses to read or write dimensions. Sales, Purchase, Manufacturing, Warehouse, Fixed Assets, and Job posting all call into this codeunit; the codeunit is also how BC-internal UI controls retrieve the dimension values shown on a document.
A modification here propagates to every posted dimension, across every module. The blast radius is not bounded by "we only customised Sales" — a change to the merge logic that Sales happens to exercise may shift dimensions on a Job Journal that shares no code with Sales. Subscribers to codeunit 408's integration events are safe; direct modifications are a high-risk change that frequently produces silent drift (dimension values on ledger entries that are defensible line by line but produce wrong totals in the financial statements).
## Best Practice
Extend only via the published integration events or by subscribing to business-layer events that codeunit 408 emits during its lifecycle. Review every dimension-related extension as part of every BC upgrade to confirm its event subscribers still fire.
## Anti Pattern
Writing to Dimension Set Entries (table 480) from custom code to "fix" a miscategorised entry. The set is shared across many entries; editing the set retroactively reclassifies every entry that referenced it, usually in ways the author did not intend.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "High-Risk Areas") on 2026-04-21.

View file

@ -0,0 +1,28 @@
---
bc-version: [1..99]
domain: finance
keywords: [dimension-combination, blocked-combination, dimension-matrix, dimension-value-combination]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# Dimension combinations
## Description
Dimension Combinations (tables 350 — `Dimension Combination`, and 351 — `Dimension Value Combination`) are the guardrail that restricts which dimension values may coexist on the same posting. Table 350 records pair-level rules for two dimensions (typically Global Dimension 1 and Global Dimension 2): the pair may be Blocked, Limited (only specific value pairs allowed), or blank (free). Table 351 records the allowed value pairs under a Limited combination. The check runs during posting via codeunit 408; when a rejected pair arrives on a Dimension Set, posting fails with a specific error naming the blocked combination.
Combinations are the right mechanism for organisational rules like "the Marketing department cannot post to the Factory location" — they enforce once, at post time, across every document type. They are the wrong mechanism for user-input validation (they do not fire until post), and they are often surprising to users who see them for the first time years into a deployment because they were set up once and forgotten.
## Best Practice
When introducing a new blocked combination, run a what-if query against open documents and journal batches first. Existing lines whose dimensions already violate the new rule will fail posting as soon as the rule activates; fix those lines before turning it on.
## Anti Pattern
Using Dimension Combinations to simulate permission checks. They gate posting, not data entry, and they fire in every module — a combination added to enforce a Sales workflow may suddenly block a General Journal entry no one expected.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Dimensions — The #1 Source of Finance Issues") on 2026-04-21.

View file

@ -0,0 +1,28 @@
---
bc-version: [1..99]
domain: finance
keywords: [dimension-default, dimension-priority, posting-conflict, dimension-merge, troubleshooting]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# Dimension default priority
## Description
At post time, Business Central merges dimension values from several sources into the final Dimension Set for each ledger entry. The merge honours a priority order: document header defaults (copied from customer/vendor at document creation) are the base, document line defaults overlay header (customer/vendor/item/G/L account defaults applied line by line), and line-level user edits overlay the defaults. For Gen. Journal posting, G/L Account default dimensions are applied inside codeunit 12 after the line is otherwise finalised. Dimension Combinations (tables 350/351) are a final gate that may reject the merged set outright.
The single most common posting error in finance is a conflict during this merge: a line-default mandatory dimension conflicts with a header-default Same Code rule, or a Dimension Combination rejects a pair that neither source knew about. Users see the error only at post time, often long after the values were set. Troubleshooting requires tracing back through each source layer.
## Best Practice
When a post fails on dimensions, inspect in this order: (1) the error message's named conflict, (2) Default Dimensions on every master referenced by the document (customer, vendor, items, G/L accounts), (3) the Dimension Combination matrix, (4) document-header dimensions for staleness (a header-level change does NOT cascade to existing lines; if the header was re-coded after lines were entered, line defaults no longer match).
## Anti Pattern
Manually forcing a Dimension Set ID on a ledger entry to bypass the merge. The entry then carries a set that does not match its source defaults, and next-period reports silently disagree with the journal audit trail.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (sections: "Dimensions — The #1 Source of Finance Issues", "Dimension Conflict Troubleshooting") on 2026-04-21.

View file

@ -0,0 +1,30 @@
---
bc-version: [1..99]
domain: finance
keywords: [dimensions, default-dimension, dimension-set, codeunit-408, global-dimension]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# Dimensions
## Description
Dimensions are analytical tags — Department, Project, Region, etc. — attached to every posting so financial reports can filter and group by business attributes without adding columns to every ledger table. Two representations coexist: Default Dimensions (table 352) declare per-record defaults on masters (customer, vendor, item, G/L account, employee), and Dimension Set Entries (table 480) record the actual combinations carried on each ledger entry. A Dimension Set ID on a ledger entry references the exact set of dimension values; multiple entries sharing a set reuse the same ID rather than duplicating rows.
Codeunit 408 (`Dimension Management`) is the central broker: it resolves defaults into concrete sets at posting time, deduplicates sets, and enforces dimension combination rules. It also owns the merge logic that walks document header, document line, and master-data defaults to produce the final set.
Default Dimensions carry one of four rules per dimension: Code Mandatory (posting blocks without a value), Same Code (the value must match the master's default exactly), No Code (posting blocks if any value is provided), or blank (free choice, no constraint). The rule is enforced at post time, not at entry; user-interface entry may allow setting values that later fail posting.
## Best Practice
Set Global Dimension 1/2 on every master that drives dimension analysis; this prepopulates document lines without users remembering to add them.
## Anti Pattern
Introducing a new required dimension mid-year without backfilling existing open documents. Posting will fail for every document whose header was created before the rule existed.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Dimensions — The #1 Source of Finance Issues") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Foundation\Dimensions\`.

View file

@ -0,0 +1,30 @@
---
bc-version: [1..99]
domain: finance
keywords: [entry-application, remaining-amount, payment-discount, payment-tolerance, codeunit-226, codeunit-227]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# Entry application
## Description
Entry application is the mechanism by which payments close invoices, credit memos offset invoices, and refunds close credits. A customer ledger entry carries a Remaining Amount that tracks the unapplied balance; an application event (Apply Customer Entries / Apply Vendor Entries) reduces Remaining Amount on both sides of the application until one side hits zero. Codeunit 226 (`CustEntry-Apply Posted Entries`) handles customer applications; codeunit 227 (`VendEntry-Apply Posted Entries`) handles vendors. Both route through codeunit 12 for the G/L posting and write Detailed Cust./Vendor Ledger Entries that preserve the application history.
Two tolerances add flexibility. Payment Discount gives a counterparty a reduced amount if they pay within a grace window; when the payment matches the discounted amount, the invoice closes and a discount-expense G/L Entry records the difference. Payment Tolerance lets a slightly short payment still close an invoice; the shortfall posts to a Payment Tolerance account. Both are configured on the Sales & Receivables Setup and Vendor Posting Groups; both can be disabled per customer/vendor.
Applications across currencies trigger an exchange-rate adjustment inside the application itself: the FCY amounts apply directly, but the LCY equivalent of each leg may differ because the rates at posting dates differ. The difference posts to an exchange gain/loss account as part of the application, independent of the period-end Adjust Exchange Rates batch.
## Best Practice
Let the Apply action compute the amounts. Manually setting Amount to Apply on one side and letting the other auto-calculate produces rounding that can leave tiny (0.01) Remaining Amounts that block closing the period.
## Anti Pattern
Scripting direct updates to Remaining Amount to close out a balance. The Detailed Cust./Vendor Ledger Entry chain no longer matches and the entry, while appearing closed, cannot be un-applied or reversed cleanly.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Entry Application") on 2026-04-21.

View file

@ -0,0 +1,28 @@
---
bc-version: [1..99]
domain: finance
keywords: [exchange-rate, adjust-exchange-rate, report-595, detailed-ledger-entry, unrealized-gain-loss]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# Exchange rate adjustment
## Description
Open foreign-currency ledger entries accumulate unrealized gain or loss as the exchange rate drifts from the posting-date rate. Report 595 (`Adjust Exchange Rates`) is the period-end batch job that revalues every open customer, vendor, bank, and G/L entry against the rate at the adjustment date. For each entry, it computes the rate delta, posts a Detailed Cust./Vendor Ledger Entry (or G/L Entry for bank and G/L accounts) that brings the LCY value back in line, and posts the offset to the configured Unrealized Gains/Unrealized Losses account. The next run reverses the prior adjustment before posting a new one, so the unrealized accounts only ever carry the current-period difference.
Running this batch is the hinge between period-end reporting and correct FCY balances. Skipping a period leaves the LCY equivalent of open balances stale; the next run has to absorb two periods of drift into one, producing a large unrealized swing that auditors flag. Running it twice in the same period on the same data is safe — the reversal mechanism makes the operation idempotent as long as the rate table has not changed.
## Best Practice
Schedule the batch as part of the month-end close, after posting the last FCY transactions and before freezing the period. Store the Exchange Rate table values the batch used so that re-running against "today's rates" later can be reconciled against the month-end snapshot.
## Anti Pattern
Running the batch without first verifying that the Currency Exchange Rate table has entries for the adjustment date. BC silently uses the most recent earlier entry, which on a missing-rate day can be weeks stale and produces an unrealized swing with no economic meaning.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Multi-Currency — Rounding and Exchange Rates") on 2026-04-21.

View file

@ -0,0 +1,28 @@
---
bc-version: [1..99]
domain: finance
keywords: [general-journal, journal-line, journal-posting, journal-types, gen-jnl-post-line]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# General journal posting
## Description
Journal posting is the freeform pathway into G/L. A user fills lines in a journal batch (table 81 — `Gen. Journal Line`) and runs post; codeunit 12 (`Gen. Jnl.-Post Line`) processes each line into the appropriate ledger entries. The same table serves several journal types, distinguished by their template/batch combination: General (generic postings), Payment (outgoing payments with applying logic), Cash Receipt (incoming payments with applying logic), Recurring (allocations and accruals with date formulas), and IC General (intercompany variants that replicate to partner companies).
Before the post, codeunit 13 (`Gen. Jnl.-Check Line`) validates each line — balancing, dimensions, account existence, posting restrictions. A failure there halts the entire batch; partial posts are not possible within a balanced transaction set. A single journal batch may contain many balanced transactions; each transaction is identified by a matching Document No. and must balance to zero across debits and credits.
## Best Practice
Separate unrelated postings into distinct balanced transactions (distinct Document No. values) within the batch. This makes a failure easier to locate and lets un-failed transactions still post if the check is run line-by-line.
## Anti Pattern
Stacking many unrelated postings under one Document No. A single validation error then blocks everything and the user must hunt for the offending line inside the balanced group.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Chart of Accounts & G/L Posting") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Finance\`.

View file

@ -0,0 +1,28 @@
---
bc-version: [1..99]
domain: finance
keywords: [gl-entry, ledger, posting, subledger, immutable]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# General Ledger entries
## Description
G/L Entries (table 17) are the ultimate destination of every monetary posting in Business Central. Every sub-ledger entry — Customer Ledger Entry (21), Vendor Ledger Entry (25), Item Ledger Entry (32), Fixed Asset Ledger Entry (5601), Bank Account Ledger Entry (271) — produces corresponding G/L Entries that update account balances. The sub-ledgers exist to carry dimension-specific analytical data (due date, item number, reservation); G/L Entries are the canonical financial record.
G/L Entries are immutable. Reversing a mistake requires a corrective posting (often via `Reverse` on the original entry), not modification. The Entry No. column is monotonically increasing, so applications ordering entries by Entry No. see insertion order; they should not assume any relationship between Entry No. and Posting Date.
## Best Practice
When reading G/L Entries in a report, filter on Posting Date and Global Dimension 1/2 Code rather than on numeric Entry No. ranges — ranges are not stable across companies and break when entries are reversed and re-posted.
## Anti Pattern
Modifying G/L Entry columns directly in custom code to "fix" a posting error. The sub-ledger entries and supporting tables (Detailed Cust./Vendor Ledger Entry, VAT Entry) remain unchanged and diverge from G/L, producing an off-balance state that only surfaces at period-close reconciliation.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Chart of Accounts & G/L Posting") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Finance\`.

View file

@ -0,0 +1,28 @@
---
bc-version: [1..99]
domain: finance
keywords: [multi-currency, rounding, currency-precision, invoice-rounding, fcy-lcy]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# Multi-currency rounding
## Description
Every Currency record (table 4) declares four rounding precisions that govern how BC handles foreign-currency (FCY) amounts: Amount Rounding Precision (typically 0.01), Unit-Amount Rounding Precision (typically 0.00001, used for unit prices), Invoice Rounding Precision (typically 0.01, the tolerance that lets an invoice round to a "clean" final amount), and Appln. Rounding Precision (tolerance for closing applications across currencies). The four precisions interact with the local currency's precision to determine what amounts a document ends up posting.
At post time the codeunit computes three amounts per line: the FCY amount (rounded to Amount Rounding Precision), the LCY amount (FCY × exchange rate, rounded to the local currency's precision), and any residual that falls to the Invoice Rounding account configured on the Customer/Vendor Posting Group. Rounding conflicts appear when: (a) LCY precision is coarser than FCY — JPY bookkeeping with EUR documents rounds to whole yen but allows 0.01 EUR; (b) per-line rounding on a many-line document diverges from single-line rounding of the document total; (c) the exchange rate changes between an order's receipt and its invoice, and the rounding residual shifts.
## Best Practice
Use the Invoice Rounding account purposefully — set it to a dedicated G/L account so the residuals aggregate where finance can review them. A catch-all "other income" lumps them with real transactions and hides rounding drift.
## Anti Pattern
Disabling Invoice Rounding by setting the precision to 0. The residuals then split across every VAT and payment account, making period-end reconciliation a hunt for pennies that do not belong to any transaction.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "Multi-Currency — Rounding and Exchange Rates") on 2026-04-21.

View file

@ -0,0 +1,28 @@
---
bc-version: [1..99]
domain: finance
keywords: [unrealized-vat, vat-realization, payment-application, vat-entry, deferred-recognition]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# Unrealized VAT
## Description
Unrealized VAT defers the VAT liability to the moment payment settles rather than the moment the invoice posts. When enabled on a VAT Posting Setup cell, posting the invoice creates a VAT Entry with a zero amount in the Amount column and the full amount in Unrealized Amount. When a payment applies to the invoice via codeunit 226 (`CustEntry-Apply Posted Entries`) or 227 (`VendEntry-Apply Posted Entries`), additional VAT Entries are created that move the amount from Unrealized to Realized in proportion to the payment applied. Partial payments realize partial VAT.
This matters for three reasons. First, the VAT return runs on realized entries only, so the period the liability is declared depends on payment date, not invoice date. Second, the chain of VAT Entries grows: one per application event. Third, reversing an application (un-applying entries) creates mirror VAT Entries that reverse the realization — never edit existing entries. The mechanism is well-defined; bugs usually stem from assumptions that VAT always realizes at posting.
## Best Practice
When migrating a company onto Unrealized VAT, take the effective-date approach: new invoices carry the new setup, historical open invoices post-realize at payment under the old setup. Mixing both setups on the same open invoice produces a VAT Entry chain that does not balance.
## Anti Pattern
Expecting the VAT account to equal the invoice's VAT amount immediately after the invoice posts. Under Unrealized VAT, the account is zero until the first payment applies. Reports that compare invoice VAT to G/L VAT balance must filter by realization state or they report every unpaid invoice as an error.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "VAT Calculation") on 2026-04-21.

View file

@ -0,0 +1,28 @@
---
bc-version: [1..99]
domain: finance
keywords: [vat-prepayment, prepayment-chain, credit-memo, rounding, proportional-adjustment]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# VAT on prepayment chains
## Description
When a Sales or Purchase document carries a prepayment percentage, Business Central splits VAT across the prepayment and the final invoice. The prepayment invoice posts VAT on the prepayment percentage of the order; the final invoice posts VAT on the remaining portion and contains a deduction line that reverses the prepayment's VAT share. If a credit memo reverses either leg, its VAT must proportion across whatever has already posted. This chain — prepayment invoice → final invoice → optional credit memo — must reconcile to the same VAT amount a one-shot invoice would have produced.
The chain is rounding-sensitive: each leg rounds independently per the VAT posting setup, and the sum of rounded legs can differ from rounding the total once. In multi-currency chains, each leg may use a different exchange rate (posting date differs), further complicating reconciliation. Symptom: the VAT account carries a 0.01 or 0.02 residual after all legs post; no single posting caused it, but the chain does not balance to the expected single-invoice equivalent.
## Best Practice
Let Business Central compute and post the VAT on every leg rather than overriding it. The proportional-adjustment logic inside codeunit 80/90 expects to own these amounts; manual overrides produce residuals that only surface at VAT return time.
## Anti Pattern
Correcting a prepayment-chain mismatch by modifying the VAT Entry on the final invoice. The entry is linked to the G/L Entry and the sales invoice line; editing it desynchronises the three and the VAT return aggregates the wrong number. Post a corrective document instead.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "VAT Calculation" and "VAT Edge Cases That Cause Triage Issues") on 2026-04-21.

View file

@ -0,0 +1,28 @@
---
bc-version: [1..99]
domain: finance
keywords: [vat, vat-posting-setup, vat-business-group, vat-product-group, reverse-charge, full-vat]
technologies: [al]
countries: [w1]
application-area: [finance]
---
# VAT posting setup
## Description
VAT Posting Setup (table 325) is the matrix that tells Business Central how to compute VAT for every combination of VAT Business Posting Group (who the counterparty is — domestic, EU, export) and VAT Product Posting Group (what is being transacted — standard goods, reduced-rate goods, exempt services). Each cell of the matrix declares the VAT % and the VAT Calculation Type that applies when that combination appears on a posting.
Three calculation types cover the common cases. Normal VAT applies the rate as a percentage of the line amount — the standard sales/purchase tax path. Reverse Charge VAT records the VAT on both sides of the transaction without a cash movement; the buyer, not the seller, is responsible for remitting it to the authority. Full VAT treats the entire line amount as VAT with no underlying taxable base — used for VAT-only correction documents. Every posted line in the document flows through the matching cell; misconfigured cells produce posting errors, incorrect returns, or off-balance VAT accounts.
## Best Practice
Populate the full matrix, including "not applicable" cells (with zero rate and a note). Missing cells produce an error message that names the combination the user tried to use, which is clearer than an unexpected zero-rate post that would mask the misconfiguration.
## Anti Pattern
Creating a single catch-all VAT Business Group for "everyone" and a single VAT Product Group for "everything." Reporting the VAT return later becomes impossible because every transaction collapses into one cell; the Authority requires transaction-level breakdown.
## Provenance
Migrated from microsoft/BCAppsTriage's `plugins/triage/skills/triage/references/area-knowledge/finance.md` (section: "VAT Calculation") on 2026-04-21. To be refined in Phase 2 from `D:\Repos\NAV\App\Layers\W1\BaseApp\Finance\VAT\`.

View file

@ -0,0 +1,101 @@
---
kind: action-skill
id: bc-domain-context
version: 1
title: BC domain context
description: Returns Business Central domain-knowledge references for a task's application area.
inputs: [file-path, repository]
outputs: [findings-report]
bc-version: [1..99]
technologies: [al]
countries: [w1]
application-area: [all]
---
# BC domain context
Surfaces the Business Central domain-knowledge files that apply to a task's application area. This is a leaf action skill — it invokes no sub-skills and produces informational findings that cite the relevant knowledge files. Consumers that need to reason about a BC module (triage bots, code assistants, review helpers) invoke this skill, read the cited files, and bring that content into their own context.
The skill produces a single JSON document conforming to the DO output contract.
## Source
Collect knowledge files under `*/knowledge/<area>/**/*.md` for every value in `task-context.application-area`, across every enabled layer (`/microsoft/`, `/community/`, `/custom/`). When `application-area` is absent, empty, or `[all]`, source from every area-named knowledge folder across the enabled layers — the full domain corpus.
## Relevance
Apply the frontmatter matching rules defined in READ (*Frontmatter matching semantics*) against the task context:
- `bc-version` — match the task's BC version. If the orchestrator did not supply one, the dimension is `unknown`.
- `technologies``[al]`. Knowledge files that declare other technologies (e.g. `[powershell]`) must still intersect with `[al]`; discard those that do not.
- `countries``[w1]` matches any task context; country-specific files match only when the task-context `countries` overlaps the file's declared countries.
- `application-area` — the file's `application-area` must include every value the task supplied, OR be `[all]`.
Discard files that are not applicable. Retain conditionally applicable files (any dimension `unknown`) only when the orchestrator's configuration permits them; findings derived from those files MUST have `confidence` no higher than `medium`, AND the `message` MUST name the dimensions that were unknown.
## Worklist
Narrow the relevant set to the files that will be cited:
1. **Goal-directed narrowing.** When `task-context.goal` contains concrete domain tokens beyond the `bc-domain-context for <area>` prefix (for example, *"VAT on prepayment credit memo"*, *"flushing method scrap"*, *"warehouse directed pick"*), score each candidate's `keywords`, filename, and `## Description` content against those tokens. Keep the highest-scoring 15 files. Ties are broken by keyword-overlap count, then filename specificity.
2. **Full-area fallback.** When the goal contains no tokens beyond the prefix (the consumer wants the whole area), skip scoring and keep every relevant file.
3. **Layer precedence.** Resolve conflicts per READ: `/custom/` wins over `/microsoft/`, `/microsoft/` wins over `/community/`. For knowledge files sharing the same `<domain>/<slug>.md` path across layers, keep the highest-precedence file and record each suppressed file in `suppressed[]` with `reason: "layer-precedence"`. Files hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.
If the post-conflict worklist is empty because no area knowledge applies to the task, emit `outcome: "no-knowledge"`. If the relevance filter ruled out every file because of a mismatch (e.g. the task targets a BC version no file supports), emit `outcome: "not-applicable"`.
## Action
For each worklist file, emit one finding:
- `id` — the file's repo-relative path (per DO, citation-based findings use the primary reference path as the id).
- `severity``info`. This skill never blocks; it is purely informational.
- `message` — the file's H1 title followed by the first two to three sentences of its `## Description` section. Strip leading whitespace and heading markers. When any frontmatter dimension was `unknown` during Relevance, append `" (conditional on: <dimensions>)"` to the message.
- `location` — omitted. Findings from this skill are not tied to a source-code location.
- `references` — a single reference object: `{ "path": "<repo-relative>", "sha": "<commit-sha>" }`. Include `sha` when the consumer invoked the skill against a specific BCQuality commit.
- `confidence``high` when every frontmatter dimension matched exactly; `medium` when any dimension was `unknown`.
Populate `summary.counts` with every emitted finding counted as `info`. Populate `summary.coverage` with `worklist-size` and `items-evaluated` — both equal the number of worklist files when the skill finishes normally.
## Output
Conforms to the DO output contract. A populated example for a finance-area task:
```json
{
"skill": { "id": "bc-domain-context", "version": 1 },
"outcome": "completed",
"summary": {
"counts": { "blocker": 0, "major": 0, "minor": 0, "info": 13 },
"coverage": { "worklist-size": 13, "items-evaluated": 13 }
},
"findings": [
{
"id": "microsoft/knowledge/finance/vat-on-prepayment-chains.md",
"severity": "info",
"message": "VAT on prepayment chains. The VAT amount on a prepayment invoice is computed on the prepayment percentage, then adjusted when the final invoice posts and again when a credit memo reverses either leg. Each step must reconcile against the sales-header prepayment account to avoid rounding drift.",
"references": [
{ "path": "microsoft/knowledge/finance/vat-on-prepayment-chains.md" }
],
"confidence": "high"
}
],
"suppressed": []
}
```
The empty-corpus case — the state before any area knowledge lands in BCQuality — produces:
```json
{
"skill": { "id": "bc-domain-context", "version": 1 },
"outcome": "no-knowledge",
"summary": {
"counts": { "blocker": 0, "major": 0, "minor": 0, "info": 0 },
"coverage": { "worklist-size": 0, "items-evaluated": 0 }
},
"findings": [],
"suppressed": []
}
```