Build-boundary ownership manifest + legacy-edge register
Machine-readable enforcement data for ADR-039 P19 / DC-40 (one microservice = one closed, independently buildable transitive source graph). Created by SPRINT_20260730_001 MBI-1.
| File | Role | Maintained by |
|---|---|---|
ownership-manifest.json | Classifies every project reachable from a production deployable key: service-family:<id>, cross-service-client-sdk:<producer>, domain-neutral-shared, test-or-e2e-only, tool-or-plugin-artifact. Longest-prefix rules; an unmatched reachable project fails generation. Also declares buildFlags, the MSBuild property matrix used to evaluate conditional references (see below). | Hand-edited; every change is an ownership decision (cite evidence). |
isolated-publish-matrix.json | Artifact-side evidence: every deployable key published with dotnet publish -c Release in both configurations, with the compiled-project list read from the published deps.json, the restored-package set pinned by digest, the output identity, and the exit code. The conformance pack can only see what a ProjectReference declares; this sees what the compiler actually compiled. | Generated by tools/scripts/build-boundary/run-isolated-publish-matrix.sh; keyed on the src/ git tree. |
legacy-edge-register.json | ZERO-PIN MODE since 2026-09-16. Pinned legacy violations: one pin per (consumer key × producer family) plus libraryImpurityPins for shared/client libraries with dirty closures. Every pin names reason, target seam, owning sprint, sunset, dated shortest witness, and — for opt-in-only edges — an activation block. Shrink-only: counts only decrease, resolved pins are deleted, new violations are never pinned — they are fixed. The burn-down finished: pins and libraryImpurityPins are both empty and LegacyRegisterPinCeiling is 0, so the shrink-only rule has become an absolute one and any pin fails the pack. The file is deliberately kept rather than deleted — the ratchet asserts it exists, so deleting it would delete the guard that stops the register coming back. | Hand-edited at burn-down; validated by tooling. |
build-boundary-report.json | Generated report: per-key closures, violation pairs with shortest witnesses, allowed client SDKs, purity findings, the conditional-edge census, and the dated 47/49 baseline reproduction. Do not hand-edit. | tools/scripts/build-boundary/generate-build-boundary-report.ps1 |
legacy-edge-resolvers.json | Retirement ledger: one entry per pair that was pinned and is not any more. Machine fields (retiredDate, deregisteredIn, pinnedOwningSprint, pinnedTargetSeam) are derived from the register’s own git history; human fields (status, resolverCommit, resolverCitation, supersededBy, note) are preserved across refreshes and are the only place a resolver is asserted. Deregistration is not resolution — the commit that deleted the pin is often a bookkeeping sweep days after the fix. | generate-legacy-edge-dispositions.ps1 -UpdateLedger, then hand-record the resolver. |
legacy-edge-dispositions.md | Generated human-readable table: every live pin as a row (seam, class, carrier, sunset, activation) grouped by owning program, plus the retirement ledger as a burn-down section. Do not hand-edit. | tools/scripts/build-boundary/generate-legacy-edge-dispositions.ps1, invoked by the report generator in the same pass. |
legacy-edge-dispositions-2026-08-06-mbi4-compilation.md | Frozen MBI-4 hand compilation (2026-08-06): gate-coverage findings, the undecided remainder, register-hygiene list and criteria state. Point-in-time evidence — not current pin state, and not rewritten. Also the seed input for the ledger’s struck-row citations. | Frozen; append a currency banner rather than editing. |
client-sdk-seam.md | Developer-facing pattern for the allowed seam: what may live in a closed contract/client project, how to extract one, additive compatibility and the n/n-1 window, release order, deprecation window, the optional contract-package path (pack + offline locked restore + tamper), and air-gap promotion. | Hand-written (MBI-3). |
Workflow
# regenerate the report AND legacy-edge-dispositions.md after any
# csproj / manifest / register change (one pass, both artifacts)
pwsh tools/scripts/build-boundary/generate-build-boundary-report.ps1
# CI / verification mode: fails on stale report, stale dispositions table,
# unclassified projects, unpinned violations, stale pins, or any pin growth
pwsh tools/scripts/build-boundary/generate-build-boundary-report.ps1 -Check
# after retiring a pin: pick up the retirement from git history, then record
# the resolving commit by hand in legacy-edge-resolvers.json and regenerate
pwsh tools/scripts/build-boundary/generate-legacy-edge-dispositions.ps1 -UpdateLedger
# Evidence EVD-9/EVD-10 only: prove the four target pairs move as one source
# transition. The default accepts exact pre or complete post; the window gate
# requires post. -SelfTest proves mixed/reclassified/pinned states stay red.
pwsh tools/scripts/build-boundary/check-evidence-p19-transition.ps1 -SelfTest
pwsh tools/scripts/build-boundary/check-evidence-p19-transition.ps1 -RequirePost
The dispositions table is generated from the register + the retirement ledger, so it cannot disagree with the register (ruling 2026-08-17, after the hand-maintained table reached ~65 of 151 rows naming pins that no longer existed and nine selective strikes implied the rest had been checked). -UpdateLedger is the only mode that invokes git, and it never runs from -Check or from a regeneration.
Do NOT run -UpdateLedger in a shallow clone — it silently DESTROYS the ledger. Measured 2026-08-23 (MBI-5): the machine fields are re-derived from the register’s git history on every run, so in a clone whose history does not reach the retiring commit the derivation yields null and overwrites the populated value. One run against a shallow clone (git rev-parse --is-shallow-repository → true, 223 commits, oldest 2026-08-20) nulled deregisteredIn, pinnedOwningSprint and pinnedTargetSeam on 158 of 169 entries (retiredDate survives — it is the one derived field the code keeps when already set) and also dropped the hand-added METHOD NOTES paragraph from description, because that prose was not part of the generator’s template constant. The 2026-08-27 Q-5 integration corrected that second defect: -UpdateLedger now preserves the existing human-authored description and uses the template only for a new or empty ledger. Nothing warns about missing history: the run prints its usual counts and exits 0. Check shallowness first; in a shallow clone, append the ledger entry BY HAND in retiredDate, consumerKey, producerFamily ordinal order (that is the generator’s own sort key, so a later full-clone refresh will not reorder it) and then regenerate the dispositions table with a plain run.
State of THIS clone, re-measured 2026-09-15 (MBI-5a): it is no longer shallow — git rev-parse --is-shallow-repository → false, 13,040 commits, oldest 2025-10-07 — so the hazard above does not currently apply here and -UpdateLedger is the correct tool again. Re-check before relying on it rather than trusting this line; a clone’s depth is a property of how it was made, not of the repository. The warning had a measured side-effect worth knowing about: the first full-clone -UpdateLedger run after it was written moved the ledger 217 → 241, i.e. twenty-four retirements from the 2026-09-09…14 family lanes had never been recorded at all, and one surviving entry still carried a deregisteredIn: null from the original shallow damage. The by-hand fallback this section prescribes is evidently not being done, so a correct warning produced a silent twenty-four-entry backlog. If that recurs, the fix is for -UpdateLedger to detect shallowness and refuse, rather than for the README to ask lanes to remember.
Output is deterministic and offline (pure XML parse of csprojs; no MSBuild/NuGet/network; no timestamps; ordinal ordering; LF). Re-running on an unchanged tree is byte-identical. Ordinal ordering became true on 2026-08-11 (MBI-7): every sort went through PowerShell’s Sort-Object, which is culture-aware, so array order followed the machine’s collation and flipped under DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1. The report was a function of the tree and the machine.
What is verified where — and what a green does NOT mean
Added by MBI-7, after three sprints hit the same property on 2026-08-05 (011 EVD-3, 024 INT-4, 025 OK-4): the generator invokes git nowhere and the conformance pack reads the same way, so -Check reporting OK is a statement about the working tree and never about HEAD. Four checks now cover the ground, and they are not interchangeable:
| Check | Where it runs | What it catches | Where it is blind |
|---|---|---|---|
Estate_EveryCrossServiceBuildEdgeIsPinnedAndShrinkOnly (pack) | PR-blocking architecture lane | a register edited ahead of its source — the pins are re-derived from the tree under test, so a deleted-but-live pin surfaces as UNPINNED | nothing about the generated report, which it never opens |
Estate_CommittedReportIsDerivableFromTheSourceAtThisCommit (pack) | PR-blocking architecture lane | a report committed ahead of its source: per-key closures, pair sets, foreign projects, witnesses, opt-in-only pairs, impurities and summary counts are re-derived and compared | topLevelSurvey, conditionalEdges, baseline.keysWithForeignTopLevels — not re-derivable in the pack |
check-boundary-inputs-at-head.sh --against index | pre-commit (tools/scripts/hooks/guard-commit.sh, when a boundary artifact is staged) | the artifact you are committing was generated from a tree whose source is not in the commit — it names the modified-but-unstaged inputs | a source change that was reverted before committing (the tree is then self-consistently wrong; the pack test is what catches that) |
generate-…ps1 -Check | local, by hand | byte-exact report staleness, byte-exact dispositions-table staleness (its one generated-at line excepted) and ledger-vs-register contradictions, plus unpinned/stale/grown/activation | reads the same disk that produced the report, and no CI lane runs it |
Closure COUNTS are a floor, not a measurement — the pair/violation verdicts are not affected. Added 2026-08-17, measured by the SCN-3 lane comparing published deps.json against the walked graph: the generator resolves <ProjectReference> as written in the csproj files, so it cannot see references MSBuild injects. src/Directory.Build.props injects StellaOps.Router.AspNet — and transitively its 35-project closure — into every non-test Web SDK project:
<ItemGroup Condition="'$(UsingMicrosoftNETSdkWeb)' == 'true' and '$(IsTestProject)' != 'true'">
<ProjectReference Include="…/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
Measured effect: scanner-web report 134 / actual 134 (exact), scanner-worker report 115 / actual 125 (10 short — AspNet.Extensions, Auth.ServerIntegration, Catalog.Replication, Microservice, Microservice.AspNetCore, Plugin.Host, Plugin.Sandbox, Router.AspNet, Router.Common, Settings).
The gap is computable per host, and it is NOT about what a host declares. Corrected 2026-08-17 after an earlier draft of this section got the rule wrong: the exposure is the part of the injected closure the host’s declared graph does not already reach.
gap(host) = |I| − |I ∩ D(host)| I = Router.AspNet's closure (35 projects)
D = the host's declared-reachable closure
Measured, and it reproduces both numbers exactly:
| host | Web SDK | |D| | |I ∩ D| | gap | | — | — | —: | —: | —: | | scanner-web | yes | 141 | 35 | 0 | | scanner-worker | yes | 126 | 25 | 10 |
Both hosts are Web SDK, so both receive the injection — Web is not exempt, and neither host is exact by virtue of declaring the injected set (scanner-worker declares none of the ten; scanner-web declares only two of them directly). scanner-web nets to zero because its Policy/Authority/Attestor edges already reach all 35 transitively, so the walker finds them anyway; scanner-worker’s declared graph reaches 25, so the remaining 10 arrive only by injection and every one is missed.
The wrong reading matters practically: it implies a host could be made exact by declaring more, which would be the wrong remedy — adding declarations to satisfy a measurement tool is how you grow a build graph for no reason. The right fix is teaching the generator the injected item group.
Why this is a counting defect and not a hidden violation: all ten classify as allowed classes under this manifest — nine domain-neutral-shared, one cross-service-client-sdk (Auth.ServerIntegration) — so no pair, pin or violation verdict changes. Treat projectsInClosure as a lower bound until the generator evaluates the injected item groups, and do not quote a closure delta as a meaningful measurement (017’s task text tracks scanner-web 133→134 that way). The estate-wide adoption pin (PersistenceAdoptionConformanceTests) models this injection explicitly rather than inheriting the blind spot — with the same measurement recorded there: Router.AspNet’s closure contains no persistence library, so the edge changes no adoption verdict today, and it is modelled anyway so that stays true rather than remaining true by luck.
The identity check is vacuous on a lane, deliberately kept anyway. CI checks out HEAD, so worktree and HEAD are identical there by construction — a “does the tree match HEAD” preflight cannot fail on arrival. It runs on the architecture lane after the pack, where it stops being vacuous: it then catches a step that mutated a tracked graph input (a planted canary left behind, a generator run, a fixture write). Read a green from it as “no step moved the graph”, never as “the report is correct” — that claim belongs to the derivability test.
The preflight’s input set is a deliberate superset: the publish helper, the canonical compose file, both hand-edited JSON files, and every tracked src/**/*.csproj outside src/__Obsoleted (CoC §15.4), plus untracked csprojs under src/. Computing the exact walked closure costs a ~30 s graph walk, and the fix — commit the source too — is identical either way.
bash tools/scripts/build-boundary/check-boundary-inputs-at-head.sh --against head # post-hoc
bash tools/scripts/build-boundary/check-boundary-inputs-at-head.sh --against index # pre-commit
bash tools/scripts/build-boundary/check-boundary-inputs-at-head.sh --self-test # prove it still detects
Local generation on a dirty worktree is unaffected: the preflight is a separate script that the generator never calls, so in-progress work regenerates the report exactly as before.
Semantics (short form)
- Violation = a production key’s closure contains a project owned by another service family, a test/E2E project, a tool/plugin-artifact project, or an unclassified project.
- Allowed across boundaries:
domain-neutral-sharedprojects (their own closure must contain only neutral projects and verified client SDKs) andcross-service-client-sdkprojects (their own closure must contain only the same producer’s client SDK projects and neutral projects — never producer implementation). Both invariants are re-verified on every run; a dirty closure needs alibraryImpurityPinsentry to pass-Check. - Prefix rules match on PATH SEGMENTS, so a sibling directory does NOT inherit its neighbour’s rule — and this bites every time a project is extracted next to an existing one. The rule for
src/__Libraries/StellaOps.Doctordoes not coversrc/__Libraries/StellaOps.Doctor.Contracts, even though the second string starts with the first. That is correct behaviour, but it reads like it should have matched, so the resulting red looks like a tooling bug rather than a missing row. Two lanes hit it on 2026-08-17 with opposite symptoms and neither suspected the manifest first: a library extracted to a sibling path came back unclassified (DOC-4b-C, cascading into anunclassifiedviolation plus two library-impurity failures, all downstream of the one missing row), while a library extracted under an existing service directory silently inherited that directory’s default and read as a phantom cross-service edge (F-FND4-3). Extracting a project is an ownership decision — add its rule in the same change, and read a surprising red here as “which rule matched?” before assuming the graph is wrong. - Families are the CURRENT independently deployable services. Owner-approved merges (ADR-039 D14) re-classify here only when the owning program lands its stage evidence.
- A D14 merge landing BEFORE its window is represented by
approvedMergeEdges(register section, owner-approved amendment 2026-08-24) — never by an early family re-classification, never by a new pin (forbidden), and never by “intentionally red” prose. Rationale: pins are a shrink-only burn-down of the 2026-08-01 baseline, so a merge executing after the baseline had no legal representation; EVD-5 and GRA-9 both parked the ESTATE gate red on their pairs, which would have hidden a genuinely accidental sixth edge behind the expected five. Each entry is an exact(consumerKey × producerFamily)pair citing its program (the sprint stem must exist) and its owner approval; growth INSIDE the pair is deliberate (the pair is the unit of approval); the REVERSE direction is never covered; and the entry EXPIRES ON DISUSE — the moment the pair leaves the derived graph, a surviving entry turns the gate red until deleted in the same change as the family re-classification. Rules live inBuildBoundaryEvaluator.ValidateAgainstRegister(red-proved synthetically inApprovedMergeEdgeValidationTests, 5 must-fire + 2 must-not-fire) and are mirrored in the generator; the dispositions table renders them as §4a. - Baseline metric note: the dated 2026-07-30 “47/49” observation counted foreign top-level domains excluding the shared-SDK root
src/__Libraries(reproduced exactly by the report’sbaselineblock).
Conditional references and the two evaluated configurations
Added 2026-08-10 (D-ROSEP-4) after RO-7 measured the generator counting a graph no build produces. Every conditional ProjectReference in the estate arrives through an ancestor <ItemGroup Condition="…"> — not one carries the attribute on the element itself — so a parser that read only the element attribute found nothing and the report silently described the all-conditions-on graph.
Conditions are now parsed (element plus every ancestor, AND-combined) and evaluated against the manifest’s declared buildFlags in two named configurations:
| Configuration | Definition | What it drives |
|---|---|---|
default | every declared property at its declared default | perKey closures and pair counts — what an ordinary dotnet publish compiles, and the graph MBI-6’s isolated-publish matrix is compared against |
maximal | every optInSupported property forced true | violation pairs, library purity, and the register’s shrink-only pins — the union over every supported build path, so an opt-in shipping variant never becomes an unguarded place to add foreign edges |
- Declaration is mandatory and fail-closed. A conditional reference naming a property that is not in
buildFlagsfails generation, so a new build flag cannot quietly change what the gate measures. So does a condition outside the supported grammar (quoted/property operands,==/!=, joined byand/or; no parentheses, negation, or property functions). - Opt-in-only pairs — present in
maximal, absent fromdefault— must carry anactivationblock on their register pin listing declaredoptInSupportedflags. The check is two-directional: a pin that declaresactivationfor an edge the default build really compiles also fails, so an opt-in label can never excuse a real edge. Flag attribution inside the block is author-supplied and validated only for declaredness; the report’sconditionalEdgescensus is the evidence a pin author works from (it lists every conditional edge, its condition text, the properties it names, the configurations it is active in, and which deployable keys reach its consumer). - Library purity is deliberately evaluated on
maximal— strictly stronger than a default-only check, so a library cannot hide a dirty closure behind a flag. topLevelSurveystays onmaximalbecause it reproduces a dated condition-blind observation; each entry carriesprojectsInClosureDefaultConfigurationalongside so the phantom delta is visible per key.
Measured at introduction: 23 conditional references estate-wide across 4 csproj files and 7 properties; 11 of them inside deployable closures. agent-core went from 113 projects / 4 violation pairs to 23 projects / 0 pairs in the default build (its entire foreign-family graph enters through the flag-gated Agent.Host → Agent.BuildDocker.Reachability, six projects since MBI-5f), and the regional-crypto fan-out on StellaOps.Cryptography.DependencyInjection was injecting 7–11 phantom projects into 48 of the 55 keys — costing zero pins, since every gated project is itself neutral. Total pairs were unchanged at 119 (115 default-build + 4 opt-in-only), so no pin moved.
Normative rules: AGENTS.md §2.7A · CoC §8.3/§16.4 · ADR-039 P19 · DC-40 · docs/architecture/service-consolidation-review.md (build-coupling test). Program home: docs-archive/implplan/SPRINT_20260730_001_Program_microservice_build_independence.md.
