diff --git a/.specify/feature.json b/.specify/feature.json index 07ace3ce..5451f6d7 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1 @@ -{ - "feature_directory": "specs/009-catalog-binding-viability" -} +{"feature_directory":"specs/010-catalog-backstage"} diff --git a/specs/010-catalog-backstage/checklists/requirements.md b/specs/010-catalog-backstage/checklists/requirements.md new file mode 100644 index 00000000..5d8f2355 --- /dev/null +++ b/specs/010-catalog-backstage/checklists/requirements.md @@ -0,0 +1,125 @@ +# Specification Quality Checklist: Backstage Catalog Adapter — Offline Owned-Paths Snapshot Generator + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-04 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [ ] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +### The one item deliberately left unchecked + +**"No [NEEDS CLARIFICATION] markers remain" does not pass, and must not be forced to pass.** +Two markers remain, recorded under the spec's *Open questions* heading. Both are gaps at +the **ADR level**, not drafting omissions: each was traced to the point where the normative +records stop short, and each is quoted against the record that declines to decide it. + +A third question — which package owns consumer-side envelope validation and +`CatalogSnapshot` derivation — was **resolved on 2026-08-04 by maintainer decision**: its +own workspace package, separate from both the adapter and `@adrkit/core`. It is recorded +as FR-044 and under the spec's *Resolved questions* heading. Because it was load-bearing +for FR-045 through FR-049, planning is no longer blocked on it. + +| # | Question | Why it is unresolvable from the ADRs | +|---|---|---| +| 1 | How, if at all, is `allRefs` populated beyond the primary `canonicalId` in production? | Spike 009's `entity-identity.md` §2 sourced aliases from synthetic fixtures only and explicitly deferred the production mechanism as "an explicitly separate, later, out-of-scope design decision this contract does not make." No ADR makes it either. | +| 2 | What constitutes the "release evidence" component of ADR-0012 gate 4? | Gate 4 reads "clean-clone / offline / adapter-boundary / **release** evidence passing," but ADR-0020 clause 9 defers the release vehicle (publish target, tag, channel) to a later record. The first three components are specifiable now; the fourth has no decidable referent. | + +Resolving these by guess would violate the specification's own binding honesty rules (and +ADR-0012's "production limits are **not** guessed now; they must be ratified from evidence"). +They are therefore **carried forward for a maintainer decision**, which is the correct disposition +for an ADR-level gap, rather than closed by invention. Neither blocks planning: question 1 +affects only whether `duplicate-canonical-ref` is reachable outside fixtures, and question 2 +belongs with the release decision ADR-0020 clause 9 already defers. + +### Validation notes for the checked items + +- **Implementation details.** The spec names pinned external artifacts — `picomatch` with frozen + options, the Backstage validator commit `1121a4facd9e321179d0402c3f355e4a649e84d9`, SHA-256 — + as *frozen contract terms* inherited from ADR-0012, ADR-0015 and the 009 contracts, not as + design choices being made here. Under this repository's conventions a frozen, ratified + dependency pin is a requirement, not an implementation leak. No internal module layout, + function signature, or code structure is prescribed. +- **Testability.** Every functional requirement is stated as an observable input/output or + abort condition. Requirements imposing process obligations (FR-053 through FR-063) are + verifiable against recorded artifacts — frozen hashes, audit findings, observed-failing + records — as ADR-0016 requires. +- **Traceability.** Every requirement cites its normative source. Requirements derived rather + than quoted are marked as derived and carry the clause they are derived from. +- **Counts.** All quantities in the spec were counted in the cited source and the source is named + at the point of use. In particular the fatal trigger enumeration is **fifteen** for this + feature: the fourteen that `atomic-fail-closed.md` §4 requires ("MUST be one of exactly these + fourteen values"), **plus** `inadmissible-descriptor`, which ADR-0015 adds and which appears + nowhere under `specs/009-catalog-binding-viability/`. FR-035 states explicitly that fourteen is + correct for spike 009 and wrong for feature 010, and cites **ADR-0015's Condition of Acceptance + 2** — binding "on any work that cites this record" — as the direct authority, this feature being + the follow-up that condition names. ADR-0020's separate "13 required trigger classes" figure is + deliberately never cross-mixed with the fourteen- and fifteen-value enumerations. Other counted + quantities: fifteen ordered glob rules; five consumer validation steps; four ADR-0012 production + gates; sixteen placeholder descriptors in three distinct forms; the corpus file and document + counts. +- **Verified during review.** All ten relative document links resolve. The ADR-0015 validator + table transcribed into FR-016 is byte-identical to ADR-0015 lines 120–123. FR numbering is + contiguous FR-001…FR-063, SC numbering contiguous SC-001…SC-017, and every internal `FR-`/`SC-` + cross-reference falls in range. A targeted sweep found no claim about Backstage-as-a-running-system, + no release-authorization language, and no ADR-0014 rung 2 or rung 3 claim. +- **Known correction made during review.** SC-007 originally required every one of the fifteen + glob rules to be exercised by a violating pattern. Rule 15's `invalid-glob-compile-failure` is + described by its own contract as a defensive backstop "expected to never occur in practice," + so that criterion was unsatisfiable as written; it now requires rules 1–14 to be exercised by + violating patterns and rule 15's `accepted` outcome by valid ones, and explicitly states that a + run never producing `invalid-glob-compile-failure` is conformant. +- **Second correction, applied after maintainer review.** The `inadmissible-descriptor` + requirements were strengthened and two requirements added, on the authority of ADR-0015's + Condition of Acceptance 2 read in full: + - **FR-018** now states the consequence explicitly — abort, non-zero status, no usable partial + output, *identical to every other fatal trigger* — and quotes ADR-0015's "adds a failure + *class*; it removes no failure." + - **FR-019 (new)** requires that no inadmissible descriptor participate in a uniqueness + comparison, per ADR-0015's "Ordering" section. + - **FR-020** now requires **all three** record fields. ADR-0015's "Failure semantics" section + names the offending path, the failing field, **and the validator that rejected it** — three + items, where a two-item paraphrase would have dropped the validator. + - **FR-021 (new)** forbids treating duplicate detection as a proxy for validity, and requires + conformance evidence to include a descriptor that is inadmissible **and** canonically unique. + ADR-0015 records that `bulk-import` and `orchestrator` in `rhdh-plugins` "canonicalize + distinctly" and so "collide with nothing," yet "are exactly as invalid as the other fourteen"; + it concludes "[d]uplicate detection is not a validity check." + + Because the repository uses letter suffixes for tasks (`T014a`) but never for functional + requirements, the two additions were absorbed by renumbering rather than by inventing an + `FR-018a` convention: former FR-019 became FR-020, and former FR-020…FR-061 became + FR-022…FR-063. The rewrite was applied by script across definitions and prose references + together, then re-verified — numbering is contiguous FR-001…FR-063, no suffixed or sentinel + identifiers survive, and every internal reference resolves in range. + +### Scope reminder for downstream phases + +This specification is authorized by ADR-0020 toward **ADR-0014 rung 1 only**. It authorizes the +work, not the release. Planning and task generation must preserve that boundary, must not schedule +a release, and must not begin generator output before the fresh T014 → T014a oracle cycle +(ADR-0020 clause 6) and the clause 5(a) freeze/audit have both completed. diff --git a/specs/010-catalog-backstage/contracts/README.md b/specs/010-catalog-backstage/contracts/README.md new file mode 100644 index 00000000..e9540505 --- /dev/null +++ b/specs/010-catalog-backstage/contracts/README.md @@ -0,0 +1,175 @@ +# Phase 1 Contracts: Production Backstage Catalog Adapter + +**Feature**: `010-catalog-backstage` | **Companion to**: `plan.md`, +`research.md`, `data-model.md`, `quickstart.md` + +## What this directory is, and what it deliberately is not + +Spike `009-catalog-binding-viability` already designed the contracts this +feature needs. Its `contracts/` directory holds **eleven** files (counted by +`ls specs/009-catalog-binding-viability/contracts/`). This feature's job is to +**carry them into production with citation**, not to redesign them. + +So this directory is small on purpose. It contains: + +- **This register** (§1–§3) — the authoritative record of which spike-009 + contract is adopted, which is adopted with a named delta, and which is + deliberately excluded, with the authority for each. +- **Three normative files** — one restated because a **count changed**, and + two that are genuinely new because spike 009 had no counterpart. + +| File | Why it exists here | +|---|---| +| `atomic-fail-closed.md` | The trigger set changed from **fourteen** to **fifteen**. A reader must not be sent to a document that names a different number. | +| `admissibility.md` | Genuinely new. ADR-0015 postdates the spike's contract set; there is no 009 counterpart. | +| `package-boundary.md` | Genuinely new. Spike 009 built no packages, so it could not specify a boundary between two. | + +**Everything else is a citation, not a copy.** Where §2 marks a contract +`Adopted unchanged`, the spike-009 file **is** this feature's contract for +that surface, and it is authoritative in its original location. Copying it +here would create two texts that drift. + +--- + +## §1. How to read the adoption register + +Each row carries one of three statuses: + +| Status | Meaning | +|---|---| +| **Adopted unchanged** | The 009 file is this feature's contract. Cite it by path and section. Do not restate it. | +| **Adopted with delta** | The 009 file is this feature's contract **except** for the named delta. Every delta names its authority. | +| **Excluded** | Deliberately not carried forward, with the reason. Not an oversight; do not reintroduce. | + +A **delta** is a change this feature makes to the spike's design. Every delta +below names the record that authorizes it. A change with no named authority is +not a delta — it is an error. + +--- + +## §2. The adoption register — all eleven spike-009 contracts + +### Adopted unchanged (5) + +| 009 contract | Sections this feature relies on | Where used | +|---|---|---| +| `glob-dialect.md` | §1 engine/options; §3 the **fifteen** ordered rules, first-match-wins; §4 dotfile policy; §5 migration; §6 compile-once-per-run | `data-model.md` §7.1 | +| `owned-paths-annotation.md` | §1 the **five** ordered decode steps; §3 the **three** ownership states; §4 empty-string edge case; §5 determinism | `data-model.md` §6, §7.2 | +| `entity-identity.md` | §1 canonicalization; §2 alias refs synthetic-only; §3 collisions; §4 overlap-is-not-collision; §5 case-sensitivity boundary | `data-model.md` §5 | +| `input-manifest.md` | §1 closed schema; §2 the **three** version/capability rejections; §3 repository identity and §3.1 scratch-repo; §4 digests and §4.1 two-stage path validation; §5 input boundary | `data-model.md` §1, §2 | +| `snapshot-envelope.md` | §1 envelope shape and the **five-field** entity record; §2 the **five** consumer validation steps; §3 digest; §4 staleness; §5/§6 identity and isolation | `data-model.md` §9–§14 | + +**One clarification that is not a delta.** `glob-dialect.md` §1 records the +picomatch version as a fixed string. This feature **reads the version from the +resolved dependency at runtime** rather than transcribing it, so the envelope +cannot silently disagree with the matcher that actually ran. That is an +implementation instruction about how a recorded value is obtained, not a +change to the contract's content — the engine, the options, and the fifteen +rules are all untouched. `research.md` R3 verified the current resolution as +`picomatch@4.0.5` at `bun.lock` line 165. + +### Adopted with delta (3) + +#### D1 — `atomic-fail-closed.md`: fourteen → **fifteen** trigger classes + +- **Delta**: add `inadmissible-descriptor` to the closed trigger set. +- **Authority**: ADR-0015 Condition of Acceptance 2, which requires the + trigger be carried onto the atomic surfaces; spec FR-035, which states + that fourteen "is correct for spike 009 and **wrong for this feature**; it + MUST NOT be copied across." +- **Verification**: `atomic-fail-closed.md` §4 says "exactly these **fourteen** + values" and lists them at lines 52–67. `inadmissible-descriptor` appears + **nowhere** under `specs/009-catalog-binding-viability/`. +- **Consequence**: because the count itself changed, this contract is + **restated in full** at `atomic-fail-closed.md` in this directory. Cite that + file, not the 009 one, for any trigger enumeration. +- Everything else in the 009 contract — the fail-closed semantics, the + no-partial-output rule, §5's grouping of the four manifest-request-level + rejections — is adopted unchanged and restated faithfully. + +#### D2 — `composition-and-release-boundary.md`: §4 superseded for **work**, not for **release** + +- **Delta**: §4 "No Shipping Artifact — Absolute Scope Boundary" is superseded + **only** to the extent that this feature is authorized to build two real + workspace packages. §5 "Release Vehicle Is Explicitly, Permanently + Undecided" is **not** superseded; it is reinforced. +- **Authority**: ADR-0020, which authorizes the **work** and not the + **release**; ADR-0020 clause 9, which holds this at ADR-0014 **rung 1**. +- **Precise boundary**: building a package is authorized. Publishing one is + not. Scheduling, designing toward, or implying a release is not. No artifact + of this feature may claim any rung beyond rung 1. +- §1 (standalone offline generator), §2 (core/CLI isolation unaffected), §3 + (ADR-0007/ADR-0009 status unaffected) are adopted unchanged. +- §6 ("What This Plan Itself Adds to the Repository (None)") was true of the + spike's plan and is **not** true of this one: this plan's design artifacts + are followed by real package construction. Stated so no reader carries the + spike's zero-artifact claim forward. + +#### D3 — `structural-fixtures-and-corpora.md`: pins and protocol carry; fixture inventory does not + +- **Delta**: §1's three pinned commits and its **re-verify-before-execution, + fail-closed-on-drift, never-silently-substitute** protocol are adopted + unchanged. §§2–10's spike-specific fixture inventory is **not** carried + wholesale; this feature derives its own fixture set from its own FRs and SCs. +- **Authority**: the fixtures served spike FRs that this feature does not + restate; ADR-0020 clause 5 imposes a **different** corpus obligation (the + frozen, independently-audited accept corpus, `data-model.md` §17) that has + no spike counterpart. +- **Carried forward explicitly** because they remain true and are easy to lose: + §6 (absent annotation is the overwhelmingly common real-corpus case), §9 + (overlap between distinct entities is not a failure), §10 (never a silent + skip). + +### Excluded (3) + +| 009 contract | Reason for exclusion | Authority | +|---|---|---| +| `comparison-heuristics.md` | Spike apparatus. The B/C/D option comparison is a **measurement instrument**, labelled `non-authoritative` by its own contract. This feature has no options to compare — ADR-0020 authorizes one design. | User instruction; ADR-0020 authorizes a single adapter, not a comparison | +| `evidence-bundle-and-verdict.md` | Spike apparatus. The evidence-bundle and three-way-verdict machinery existed to produce a **viability verdict**. That verdict was reached; ADR-0020 **is** its outcome. Re-running the machinery would re-decide a decided question. | User instruction; ADR-0020 is the record that closes it | +| `scale-and-security-measurement.md` | Its §§1–4 scale-measurement instrument is spike apparatus, and its §4 "Not Guessed Now" rule is already carried by ADR-0012 directly. | ADR-0012's own "production limits are not guessed now" | + +**One partial retention from an excluded file, stated so it is not lost.** +`scale-and-security-measurement.md` §5 (network denial) describes the protocol +for evidencing that a run performs no network access, and `input-manifest.md` +§3.1 describes the standalone scratch repository. Those two remain relevant to +this feature's offline/clean-clone obligations (spec User Story 9). They are +cited from their original locations; the surrounding measurement instrument is +not carried. + +--- + +## §3. What is genuinely new in this feature + +Three surfaces have **no** spike-009 contract, because they did not exist when +the spike was designed: + +1. **Admissibility** (`admissibility.md`) — ADR-0015 postdates the spike's + contract set. §4 of `data-model.md` and this contract are its only + specification here. +2. **The two-package boundary** (`package-boundary.md`) — spike 009 built no + packages (`composition-and-release-boundary.md` §6), so it could not + specify a boundary between two of them. +3. **The pre-output barrier** — ADR-0020 clauses 5 and 6. This is specified in + `plan.md` as **Barrier B**, because it is a **sequencing** constraint on the + build rather than a contract on a surface. `data-model.md` §16 and §17 + define the artifacts it produces. + +--- + +## §4. Standing honesty constraints on every file in this directory + +These apply to this register and to all three normative files, and they are +repeated in each: + +1. **Never assert what Backstage-as-a-running-system does.** The only warrant + available is **what a pure validator predicate returns when invoked** at the + pinned Backstage commit `1121a4facd9e321179d0402c3f355e4a649e84d9`. +2. **Never state a count that has not been verified in the cited source.** Every + count in this directory names where it was read. +3. **This feature has produced no evidence.** Every behavioural statement here + is a **requirement**, never a report of an observation. +4. **Genuine unknowns are marked** `[NEEDS CLARIFICATION: ...]`, never resolved + by guess. +5. **ADR-0014 rung 1 only.** Maintainer reference verification MUST NOT be + called external, third-party, or community. Only corpus *data* is + third-party — never the validation. diff --git a/specs/010-catalog-backstage/contracts/admissibility.md b/specs/010-catalog-backstage/contracts/admissibility.md new file mode 100644 index 00000000..5600931d --- /dev/null +++ b/specs/010-catalog-backstage/contracts/admissibility.md @@ -0,0 +1,215 @@ +# Contract: Descriptor Admissibility + +**Feature**: 010-catalog-backstage +**Status**: New in this feature. Spike 009 has no counterpart. +**Freezes**: what "admissible descriptor" means, when the determination runs relative to +canonicalization, what a failed determination does to the run, and what a passed determination +does *not* license anyone to say. +**Normative sources**: ADR-0015 (Condition of Acceptance 2, and the four-field validator table +it fixes); `spec.md` FR-015 through FR-021; `data-model.md` §4 (`AdmissibilityResult`) and §8 +(the fatal trigger enumeration); `research.md` R6. +**Supersedes**: nothing. There is no spike-009 admissibility contract to supersede. + +--- + +## 0. Why this contract exists at all + +ADR-0015 postdates spike 009. The spike ran a fourteen-value fatal trigger enumeration and had +no notion of an inadmissible descriptor; `contracts/README.md` §2 records this as delta **D1**, +and `specs/009-catalog-binding-viability/contracts/atomic-fail-closed.md` §4 (lines 52–67, read +in this worktree) states its enumeration is "exactly these fourteen values". The string +`inadmissible-descriptor` does not appear anywhere under +`specs/009-catalog-binding-viability/`. ADR-0015 is its only source. + +Consequently nothing in the spike's contract set can be pointed at for admissibility. The +surface has to be frozen here or it is not frozen at all. + +--- + +## 1. The warrant, stated before anything else + +Every admissibility statement in this contract is a statement about **what a pure validator +predicate returns when invoked**, at Backstage commit +`1121a4facd9e321179d0402c3f355e4a649e84d9`. + +It is **not** a statement about what Backstage-as-a-running-system does with a descriptor. A +descriptor this contract calls inadmissible may or may not be rejected by a deployed Backstage +instance; this feature has never run one and will not. A descriptor this contract calls +admissible has not thereby been shown to work anywhere. + +The pin is load-bearing. A different commit is a different predicate and therefore a different +contract. Any change of pin invalidates every admissibility determination recorded under this +feature and requires re-derivation, not re-labelling. + +--- + +## 2. The four field validators + +Admissibility is the conjunction of exactly four field-level validator predicates, as fixed by +ADR-0015 and restated in `spec.md` FR-016: + +| Field | Validator | Character class | Length bound | +| --- | --- | --- | --- | +| `apiVersion` | `validateApiVersion` | — | — | +| `kind` | `validateKind` | — | — | +| `metadata.name` | `validateEntityName` | `[A-Za-z0-9]` plus `-`, `_`, `.` | ≤ 63 characters | +| `metadata.namespace` | `validateNamespace` | `[A-Za-z0-9]` plus `-`, `_`, `.` | ≤ 63 characters | + +A descriptor is **admissible** when all four predicates return true for it. It is +**inadmissible** when any one of them returns false. + +There is no partial admissibility, no warning tier, and no "admissible except for" state. The +`AdmissibilityResult` type in `data-model.md` §4 is the only carrier of the outcome. + +### 2.1 Two populations that are not the same population + +"Over 63 characters" and "invalid" are different sets and MUST NOT be reported as one. + +At the corpus pins recorded in `research.md` R14, `community-plugins` has **7** descriptors with +an invalid `metadata.name`: **5** fail on character class and **2** fail on length alone. +`rhdh-plugins` has **11**, all of which fail on character class. Any report that collapses these +into a single "too long" or single "invalid characters" figure is wrong even when its total is +right. + +--- + +## 3. The separator rule + +Per `spec.md` FR-017, a validator failure MUST be attributed to the field it was invoked on. The +composition therefore splits the descriptor's fields before invoking any predicate and never +after. Two fields failing produce two attributions, not one merged one. + +A recorded failure that says only "descriptor invalid", without naming which of the four fields +and which validator produced the false, does not satisfy FR-020 and MUST be treated as a +reporting defect rather than as a determination. + +--- + +## 4. Ordering: admissibility runs before canonicalization + +This ordering is fixed by ADR-0015 and is not an implementation preference. + +1. Read the descriptor document. +2. Determine admissibility (§2). +3. Only if admissible, canonicalize identity. + +### 4.1 The consequence that must be carried + +An inadmissible descriptor **never acquires a canonical id**. It therefore can never participate +in a `duplicate-canonical-id` determination, in either direction: it cannot be the first member +of a collision and it cannot be the second. + +This is not an optimization. Reversing the order would make some descriptors collide *before* +being found inadmissible, and the trigger class reported for the run would then depend on +document order within the manifest. Order-dependence of the reported trigger is exactly the +failure mode ADR-0015's ordering rule exists to prevent. + +--- + +## 5. Failure semantics + +`inadmissible-descriptor` is a **fatal, whole-operation** trigger class. It is the fifteenth +member of this feature's enumeration (`data-model.md` §8; `contracts/atomic-fail-closed.md` +§4.1). Its provenance is ADR-0015 Condition of Acceptance 2. + +On any inadmissible descriptor: + +- the entire run aborts; +- **no** envelope is written, including a partial one; +- **no** entity from the same run is emitted, including entities already determined admissible; +- the process exits non-zero with a machine-readable reason naming the trigger class. + +An inadmissible descriptor is **never skipped**, never downgraded to a warning, and never +excluded-and-continued. "Continue past the bad one" is the precise behaviour this contract +forbids. + +### 5.1 The count + +This feature's fatal trigger enumeration has **fifteen** members. Writing "fourteen" as this +feature's count is an error against `spec.md` FR-035, which states in terms that fourteen is +wrong for this feature. Fourteen is spike 009's count and remains correct *as a statement about +spike 009*. + +--- + +## 6. Duplicate detection is not a validity check + +Per `spec.md` FR-021: canonical-id collision is a statement about a *pair* of descriptors. It is +not a property of either one alone, and it is not an admissibility failure. + +The two determinations are independent, and conformance evidence MUST demonstrate that +independence rather than assert it. Specifically, the evidence MUST include at least one +descriptor that is **inadmissible and canonically unique** — a descriptor that fails §2 while +colliding with nothing. Without such a case, a passing suite is equally consistent with an +implementation that has silently fused the two checks. + +### 6.1 The placeholder population, and a word that means two things here + +At the pins in `research.md` R14 there are **16** unsubstituted skeleton descriptors — **5** in +`community-plugins`, **11** in `rhdh-plugins` — carrying **3** distinct placeholder forms. +**14** of the 16 share the form `${{ values.name | dump }}` and therefore canonicalize to the +same id, colliding with one another. The remaining **2** — `bulk-import` +(`${{ values.name }}`) and `orchestrator` (`${{ values.entityName }}`), both in `rhdh-plugins` — +canonicalize distinctly and collide with nothing. + +**The "fourteen" in this subsection counts placeholder descriptors. It is not the trigger +count.** These are two unrelated fourteens and a reader who fuses them will produce a document +this repository fails. This feature's trigger count is fifteen (§5.1). + +Those 2 outliers are the natural source of the §6 evidence case: each is inadmissible under §2 +and collides with nothing. + +--- + +## 7. What a passed determination does not license + +An admissibility pass warrants exactly one sentence: *the four validator predicates at the +pinned commit returned true for this descriptor's four fields.* + +It does not warrant, and MUST NOT be written as: + +- that the descriptor is valid, correct, well-formed, or accepted; +- that Backstage would ingest it; +- that the entity it describes exists, is reachable, or is owned by anyone; +- that any path derived from it is a path anyone actually owns. + +Ownership derivation is a separate contract (`owned-paths-annotation.md`, adopted unchanged from +spike 009 per `contracts/README.md` §2) and inherits its own separate warrant limits. + +--- + +## 8. Observation requirement + +Per ADR-0016 and `research.md` R8, each of the four validator predicates and the composition in +§3 lands only by three moves: + +1. construct a descriptor that should fail that specific validator; +2. run it and **observe the failure**, recording the exact reason string produced; +3. correct the input and observe the pass. + +A validator only ever observed passing has not been shown to be wired in. The §6 evidence case +(inadmissible and canonically unique) is subject to the same discipline: it must be observed +producing `inadmissible-descriptor` and **not** `duplicate-canonical-id`. + +These observations use hand-authored fixtures whose expected values come from this contract and +from ADR-0015. Their placement relative to the pre-output barrier is fixed by `plan.md` +(Phase D), including the open question about whether ADR-0020 clause 6 permits them to run +before the barrier clears at all. + +--- + +## 9. Standing honesty constraints + +Repeated here per `contracts/README.md` §4, because a contract read in isolation must carry +them: + +1. **The warrant is a predicate return value**, not the behaviour of Backstage as a system (§1). +2. **No unverified counts.** Every number in this document names where it was read: ADR-0015 and + FR-016 for the validator table, `research.md` R14 for corpus figures, `data-model.md` §8 for + the trigger enumeration. +3. **No evidence is claimed.** This feature has produced none. Every behavioural statement above + is a requirement on work not yet done, never a report of work done. +4. **ADR-0014 rung 1 only.** Nothing here is external, third-party, or community validation. + Only the corpus *data* is third-party; the validation is the maintainer's own. +5. **Genuine unknowns are marked**, not smoothed over. This contract carries none of its own; + the ones it is downstream of are carried in `plan.md` and `research.md`. diff --git a/specs/010-catalog-backstage/contracts/atomic-fail-closed.md b/specs/010-catalog-backstage/contracts/atomic-fail-closed.md new file mode 100644 index 00000000..11b4a0b1 --- /dev/null +++ b/specs/010-catalog-backstage/contracts/atomic-fail-closed.md @@ -0,0 +1,196 @@ +# Contract: Whole-Operation Atomic Fail-Closed Semantics (Production, **Fifteen** Triggers) + +**Feature**: `010-catalog-backstage` | **Freezes**: FR-030 through FR-038, +User Story 3, SC-004, SC-005. Companion to `data-model.md` §8 +(`AtomicFailureRecord`), §4 (`AdmissibilityResult`). +**Normative sources**: ADR-0012 "Atomic fail-closed semantics"; ADR-0015 +Condition of Acceptance 2. +**Supersedes for this feature**: +`specs/009-catalog-binding-viability/contracts/atomic-fail-closed.md`. + +## 0. Why this contract is restated rather than cited + +Every other adopted spike-009 contract is **cited** from its original +location (see `README.md` §2). This one is restated in full for exactly one +reason: **the trigger count changed**. The 009 contract says "exactly these +**fourteen** values" at its §4 (lines 52–67). This feature has **fifteen**. +Citing a document that names a different number would leave a reader holding +the wrong closed set. + +Everything below other than the added trigger is the 009 contract's content, +carried faithfully. + +## 1. The Rule, Stated Precisely + +**Any** invalid input encountered during a single snapshot-generation run — +including but not limited to a duplicate canonical ID/ref, a duplicate YAML +key, malformed or wrongly-shaped JSON, a rejected pattern, an unsupported +snapshot version/capability, a repository mismatch, an incomplete required +source, **or an inadmissible descriptor** — MUST abort the **entire** run with +non-zero status and produce **no usable partial snapshot**, including for +entities that would otherwise have validated cleanly in the same run. This +supersedes any narrower, per-entity reading of "fail closed." + +**The single most likely implementation mistake this contract exists to +foreclose is "skip the bad entity and keep going"** — that behaviour is +explicitly wrong under this contract, regardless of how reasonable it might +seem as a convenience. + +## 2. Distinguishing Per-Rule Validation From Whole-Operation Atomicity + +This is a **separate, whole-operation property**, distinct from the +per-pattern/per-annotation classification in +`specs/009-catalog-binding-viability/contracts/owned-paths-annotation.md` and +`.../glob-dialect.md`. + +Per-rule tests exercise each validation rule **in isolation** — one fixture, +one violated rule at a time. This contract is tested **separately**: introduce +**exactly one** invalid entity into an **otherwise-valid batch**, and confirm +the whole run aborts, producing no snapshot at all — not even for the entities +that would have validated cleanly. + +**Passing the per-rule tests does not demonstrate this contract.** The two +properties MUST be tested independently. + +## 3. Worked Example + +Given five valid entities plus a sixth with a duplicate canonical ID: + +| Step | Required outcome | +|---|---| +| Run generation over all six entities in one invocation | Exits non-zero | +| Inspect the output location for a produced envelope | **None exists** — not even one covering the five otherwise-valid entities | +| Evidence | Explicitly records that no envelope — not even a partial one — was produced or is usable, distinguishing this from a hypothetical (and explicitly rejected) partial-success outcome | + +The same table holds with the sixth entity replaced by an **inadmissible** +descriptor (§5). The consequence does not vary by trigger. + +## 4. Trigger Enumeration — Closed Type of **Fifteen** Values + +Every `AtomicFailureRecord.triggerClass` (`data-model.md` §8) MUST be one of +exactly these **fifteen** values. The type is closed — a fixed set of string +literals, never an open `string`: + +```text +duplicate-canonical-id | duplicate-canonical-ref | duplicate-yaml-key | +invalid-yaml-syntax | invalid-manifest-shape | invalid-annotation-shape | +invalid-annotation-parse | invalid-pattern | unsupported-manifest-version | +unsupported-snapshot-version | unsupported-capability | +repository-mismatch | incomplete-required-source | +inadmissible-descriptor | other-invalid-input +``` + +### 4.1 Provenance of the count + +| Source | What it says | +|---|---| +| `specs/009-catalog-binding-viability/contracts/atomic-fail-closed.md` §4, lines 52–67 | "exactly these **fourteen** values", listing all but `inadmissible-descriptor` | +| Search across `specs/009-catalog-binding-viability/**` | `inadmissible-descriptor` appears **nowhere** | +| ADR-0015 Condition of Acceptance 2 | Requires the trigger be carried onto the atomic surfaces; ADR-0015 is its only source | +| Spec FR-035 | Fourteen "is correct for spike 009 and **wrong for this feature**; it MUST NOT be copied across" | + +**Fourteen is the spike's number. Fifteen is this feature's number.** An +artifact of this feature that says fourteen is wrong. + +### 4.2 `other-invalid-input` is a deliberate backstop, not a spare slot + +The last value is a **deliberate, always-present backstop** that exists +specifically to honour the "including but not limited to" hedge in the prose +rule without leaving the data model's own type open-ended — mirroring the same +defensive-backstop pattern used for `"invalid-glob-compile-failure"` at +`glob-dialect.md` §3 rule 15. + +An implementation that encounters a genuinely new trigger class not named by +one of the **first fourteen** values records it as `other-invalid-input` — it +**never** invents an ad-hoc sixteenth string inline — and flags this contract +for update. The abort/no-partial-output consequence (§1) applies identically +regardless of which named trigger, or the backstop, fired. + +### 4.3 Two triggers that are easy to collapse into one, and must not be + +- **`duplicate-yaml-key`** vs **`invalid-yaml-syntax`**: a descriptor that + fails to parse for a YAML syntax reason **other than** a duplicate key (a + malformed scalar, an unterminated flow collection) is `invalid-yaml-syntax`. + `data-model.md` §3's `DescriptorDocument.parseOutcome` carries the same + two-way distinction. +- **`invalid-manifest-shape`** vs **`unsupported-manifest-version`**: the + former is the manifest failing to parse as JSON, or parsing with a field of + the wrong JSON type, or carrying an unrecognized top-level field (the + closed-schema rule at `input-manifest.md` §1). The latter presumes the + manifest parsed correctly **and** has the right shape, but declares an + unsupported *value* for `manifestSchemaVersion` specifically. + +## 5. `inadmissible-descriptor` — the added trigger + +**Authority**: ADR-0015 and its Condition of Acceptance 2. This feature is +ADR-0015's designated follow-up, and carrying this trigger onto the atomic +surface is the specific obligation being discharged. + +**When it fires.** A descriptor document fails the four-field admissibility +predicate specified at `admissibility.md` — that is, `AdmissibilityResult +.admissible === false` (`data-model.md` §4). + +**Ordering, and why it is load-bearing.** Admissibility is evaluated **before** +canonicalization (ADR-0015's decision that admissibility is a *precondition +of* canonicalization). The concrete consequence: an inadmissible descriptor +never acquires a `canonicalId`, so it can never participate in a +`duplicate-canonical-id` determination and can never be reported under that +trigger instead of its own. A design that canonicalizes first and checks +admissibility afterwards can produce exactly that misattribution, and is +non-conformant. + +**Severity.** Fatal and whole-operation, identical to every other trigger. +Never a per-entity skip, never a warning, never a filtered-out entity. ADR-0015 +CoA-2 is explicit that this is the point of carrying it here. + +**Warrant limit that travels with this trigger.** What is warranted is **what +the pinned validator predicate returns when invoked** at Backstage commit +`1121a4facd9e321179d0402c3f355e4a649e84d9`. It is **not** warranted that a +Backstage deployment installs the policy, that a catalog backend rejects such a +descriptor, or that "Backstage requires" these fields. See `admissibility.md` +§4. + +## 6. Four Manifest-Request-Level Rejections + +Distinct from the per-entity/per-annotation triggers, these four are properties +of the manifest/generation request **as a whole**, detailed at +`input-manifest.md` §2 and §4: + +```text +unsupported-manifest-version | unsupported-snapshot-version | +unsupported-capability | incomplete-required-source +``` + +All four abort **before any entity's paths are derived**, exactly like every +other trigger in §4. + +**Count check.** `input-manifest.md` §2 supplies **three** of them (the +version/capability table); `input-manifest.md` §4 supplies the fourth +(`incomplete-required-source`). Three plus one is the four grouped here. Both +numbers are load-bearing and neither is a typo for the other. + +## 7. What this contract does not cover + +- **Repository-identity matching's own comparison algorithm** is + `input-manifest.md` §3's concern. This contract fixes only that a + `repository-mismatch` outcome is one of the fifteen triggers and that its + consequence is identical to every other trigger's. +- **The admissibility predicate itself** is `admissibility.md`'s concern. This + contract fixes only that its failure is trigger fifteen-of-fifteen by + enumeration and fatal by severity. +- **Consumer-side envelope rejection** — a loaded envelope failing validation — + is a distinct, later-stage concern covered by `snapshot-envelope.md` §2 and + `data-model.md` §11. **This contract governs generation-time atomicity + only.** A consumer rejecting an envelope is not an instance of this rule. + +## 8. Standing honesty constraints + +1. Never assert what Backstage-as-a-running-system does; the warrant is the + pinned predicate's return value. +2. Never state a count not verified in the cited source. Every count above + names where it was read. +3. This feature has produced no evidence. Every statement here is a + **requirement**, never a report. +4. Per ADR-0016, none of these triggers counts as covered until its check has + been **observed failing** against a fixture that violates it, before the + implementation that makes it pass exists. diff --git a/specs/010-catalog-backstage/contracts/package-boundary.md b/specs/010-catalog-backstage/contracts/package-boundary.md new file mode 100644 index 00000000..0d4cd955 --- /dev/null +++ b/specs/010-catalog-backstage/contracts/package-boundary.md @@ -0,0 +1,220 @@ +# Contract: Package Boundary + +**Feature**: 010-catalog-backstage +**Status**: New in this feature. Spike 009 built no packages, so it has no counterpart. +**Freezes**: where the two new packages live, what each may depend on, the direction of the +edge between them (there is none), what the interface between them actually is, and which +duplication across them is deliberate. +**Normative sources**: Constitution v1.0.2 Principle III; ADR-0007 (independent adapter +versioning); ADR-0013 (no dynamic adapter loader); `spec.md` FR-001 through FR-005 and FR-044; +`data-model.md` cross-cutting ownership table; `research.md` R1 and R7. +**Supersedes**: nothing. + +--- + +## 0. Why this contract exists at all + +Spike 009 produced no packages — `contracts/README.md` §2 delta **D2** records that +`composition-and-release-boundary.md` §6's zero-artifact claim is no longer true for this +feature. The spike therefore froze no package placement, no dependency allowlist, and no +inter-package direction. All three have to be frozen here. + +The placement question was live enough to have been recorded as an open question in `spec.md` +and resolved by maintainer decision on 2026-08-04 (`checklists/requirements.md` "Notes"). This +contract fixes the resolution so it is not re-opened by drift. + +--- + +## 1. The two packages + +| | Generator | Consumer | +| --- | --- | --- | +| Path | `packages/adapters/catalog-backstage/` | `packages/catalog-envelope/` | +| Name | `@adrkit/catalog-backstage` | `@adrkit/catalog-envelope` (working name) | +| Kind | Adapter | Non-adapter workspace library | +| Role | Reads a manifest and descriptor files offline; writes one envelope | Reads an envelope; validates it; derives a snapshot | +| Versioning | Independent of `@adrkit/core` (ADR-0007) | Follows the ordinary workspace convention | + +Both paths are already matched by the root `package.json` `workspaces` globs +`["packages/*", "packages/adapters/*"]` (read at the repository root in this worktree). +**No change to `workspaces` is required, and none may be made.** A change there would be a +signal that the placement is wrong. + +`@adrkit/catalog-envelope` is a working name. If the maintainer renames it, only this table and +the allowlist entries in §2 change; nothing in §3 through §6 depends on the string. + +--- + +## 2. Dependency allowlists + +These are the entries to be added to `allowedDependenciesFor()` in `scripts/check-deps.ts`. They +are the design; they are not yet implemented. + +| Package | `dependencies` | `devDependencies` | +| --- | --- | --- | +| `@adrkit/catalog-backstage` | `@adrkit/core`, `picomatch`, `yaml` | `@types/bun`, `@types/picomatch` | +| `@adrkit/catalog-envelope` | `@adrkit/core` | `@types/bun` | + +`picomatch` and `yaml` are already present in the committed lockfile — declared at `bun.lock` +lines 47 and 49 and resolved to `picomatch@4.0.5` (line 165) and `yaml@2.9.0` (line 187), all +read in this worktree. Neither package introduces a new registry surface. + +### 2.1 Why the generator depends on `@adrkit/core` (decision G1) + +`research.md` R7 posed the choice: **G1**, the adapter depends on `@adrkit/core` solely for +canonicalization primitives; or **G2**, the adapter depends on the consumer. R7 adopted G1 as +the working assumption because G2 is directly forbidden by FR-044 while G1 is merely +undesirable. + +Two things sharpen that, and neither contradicts R7: + +- The generator must emit a `digest` byte-identical to one an independent recomputation + produces. `research.md` R2 fixes the primitive as `canonicalStringify` from `@adrkit/core` + (defined `packages/core/src/fingerprint/index.ts:16`, exported `packages/core/src/index.ts:24`, + ordering via `compareCodeUnits` at `packages/core/src/ordering/index.ts:12`). Re-implementing + it in the adapter would create a second definition of "canonical" that could drift silently — + which is the failure the digest exists to detect. +- Constitution Principle III describes adapters as packages that "live under + `packages/adapters/*`, **depend on the core**, and are permitted to break on upstream churn." + An adapter depending on core is the Principle's own expected shape, not a carve-out. + +**Do not import the same-named `canonicalStringify` from +`packages/evaluator/src/report/serialize.ts:38`.** It is a different function with a different +signature (`(root, pretty = false)`). Importing it would also cross a package boundary the +allowlist in §2 does not permit. + +### 2.2 Scope qualification that travels with the digest + +For the envelope's closed scalar domain, `canonicalStringify`'s bytes are *equivalent to* +RFC 8785 / JCS output. No document under this feature may claim that `canonicalStringify` is a +general-purpose RFC 8785 implementation. The equivalence is scoped to the domain, and the +qualification is part of the claim, not a footnote to it. + +--- + +## 3. Direction: there is no edge between the two packages + +Per `spec.md` FR-044: + +- `@adrkit/catalog-backstage` MUST NOT depend on `@adrkit/catalog-envelope`. +- `@adrkit/catalog-envelope` MUST NOT depend on `@adrkit/catalog-backstage`. + +**The envelope file on disk is the entire interface.** The generator writes it. The consumer +reads it. Neither package imports a symbol from the other, at build time or at runtime, and +there is no shared internal module between them. + +This is what makes the digest and the staleness check meaningful. If the consumer imported the +generator's serializer, a digest check would be comparing the generator against itself and would +detect nothing. + +### 3.1 The consumer is a non-adapter by construction + +`isAdapterPackage()` (`scripts/check-deps.ts` lines 92–94, read in this worktree) classifies a +package as an adapter purely by whether its path begins with `packages/adapters/`. Because +`packages/catalog-envelope/` does not, the consumer is a non-adapter as a matter of its location, +not as a matter of an allowlisted exception. + +Constitution Principle III's `core-has-no-adapter-deps` rule — no package outside +`packages/adapters/**` may depend on an adapter — is therefore satisfied structurally. There is +no entry anywhere granting the consumer permission to be a non-adapter; it simply is one. + +--- + +## 4. How the boundary is actually enforced, and one way it silently is not + +The two guards in `scripts/check-deps.ts` that matter here (all line numbers read in this +worktree): + +- Lines 175–182: a non-adapter workspace declaring a dependency on an adapter is a violation + with reason `non-adapter workspace depends on an adapter package`. +- Lines 196–204: a package declaring a dependency outside its allowed public surface is a + violation with reason ` declares a dependency outside its allowed public surface`. + +**The trap:** `allowedDependenciesFor()` returns `undefined` for any package it has no entry for +(`scripts/check-deps.ts:151`), and the second guard is then skipped entirely. A package with no +allowlist entry is **silently unconstrained** — it passes `check:deps` no matter what it +declares. + +Both new packages therefore require explicit entries per §2. Omitting an entry does not produce +a failure; it produces a green check that means nothing. This is the one place where the absence +of a rule is indistinguishable from a satisfied rule, and it must be closed deliberately. + +--- + +## 5. The duplication that is deliberate + +Per the cross-cutting ownership table in `data-model.md`, **both packages declare the envelope's +shape independently.** This is the single deliberate duplication in the design. + +It is deliberate because a shared type module would be an import edge, and an import edge is +exactly what §3 forbids. If both packages derived their view of the envelope from one +declaration, the consumer could not detect a generator that had changed the shape — the shape +would have changed on both sides at once. Two independent declarations are what make the +consumer's structural validation an actual check rather than a tautology. + +The cost is real and is accepted: the two declarations can diverge, and nothing but the +consumer's own validation failing will say so. That failure is the intended signal. + +No other duplication across the two packages is sanctioned by this contract. + +--- + +## 6. Independent versioning + +`@adrkit/catalog-backstage` versions independently of `@adrkit/core` per ADR-0007: its semver +contract is with the pinned Backstage predicate surface, not with `@adrkit/core`'s API. + +The §2.1 dependency on `@adrkit/core` does **not** change that, and the package MUST record so +explicitly. The precedent is the `"//versioning"` note already carried in +`packages/adapters/spec-kit/package.json` (read in this worktree); this package follows the same +form. Without that note, a reader encountering a core dependency would reasonably infer coupled +versioning, and the inference would be wrong. + +--- + +## 7. Out of scope, named so it is not done by accident + +Wiring `@adrkit/catalog-envelope` into `@adrkit/cli` (or into `@adrkit/core`) is **not** part of +this feature. + +Constitution Principle III permits core and CLI to depend on "their own workspace packages", so +the placement in §1 does not foreclose that wiring later. But doing it here would require +amending `allowedDependenciesFor('@adrkit/cli')`, whose allowlist is currently exactly +`{'@adrkit/core', '@adrkit/evaluator'}` (`scripts/check-deps.ts` lines 107–115, read in this +worktree). **Do not amend it under this feature.** + +Also out of scope, and forbidden rather than merely deferred: any dynamic adapter loader or +registry (ADR-0013; `spec.md` FR-002). The generator is invoked directly by name. Constitution +Principle III's phrase "discovery is by runtime configuration" predates ADR-0013; where the two +differ the ADR governs, per the Constitution's own precedence rule. + +--- + +## 8. Observation requirement + +Per ADR-0016 and `research.md` R8, each boundary rule lands only by being observed failing +first, then passing: + +| Rule | Failing observation to construct | +| --- | --- | +| §3 no consumer → adapter edge | Add the adapter to the consumer's `dependencies`; observe `non-adapter workspace depends on an adapter package`; remove it; observe pass. | +| §3 no adapter → consumer edge | Add the consumer to the adapter's `dependencies`; observe the allowed-public-surface violation; remove it; observe pass. | +| §2 allowlists are present | Confirm each new package has an entry by adding a disallowed dependency and observing a violation. A green `check:deps` on a package with no entry (§4) is not evidence of anything. | + +The third row is the one that is easy to skip and the only one that closes the §4 trap. + +--- + +## 9. Standing honesty constraints + +Repeated here per `contracts/README.md` §4: + +1. **The warrant is a predicate return value**, not the behaviour of Backstage as a system. This + contract makes no Backstage claim at all. +2. **No unverified counts.** Every line number, path, and version above names the file it was + read from in this worktree. +3. **No evidence is claimed.** Neither package exists. Every statement above is a requirement on + work not yet done. +4. **ADR-0014 rung 1 only.** Nothing here is external, third-party, or community validation. +5. **Genuine unknowns are marked.** This contract carries none of its own. The name + `@adrkit/catalog-envelope` is a working name, not an unknown (§1). diff --git a/specs/010-catalog-backstage/data-model.md b/specs/010-catalog-backstage/data-model.md new file mode 100644 index 00000000..8f75b9f5 --- /dev/null +++ b/specs/010-catalog-backstage/data-model.md @@ -0,0 +1,709 @@ +# Phase 1 Data Model: Production Backstage Catalog Adapter + +**Feature**: `010-catalog-backstage` | **Companion to**: `plan.md`, +`research.md`, `contracts/`, `quickstart.md` + +## Scope and status of this document + +This document defines the **types** the two packages exchange and the +**closed value domains** their discriminators range over. It defines no +behaviour that the contracts in `contracts/` do not already fix, and it +contains no executable code — every block below is a type sketch for a +reader, not a file to copy. + +Two standing rules apply to every section: + +- **Closed types stay closed.** Where a field's domain is a fixed set of + string literals, that set is exhaustive and an implementation must model it + as a union of literals, never as an open `string`. +- **Discriminators are carried explicitly.** Where two states produce the same + observable payload (most importantly `explicit-empty` versus + `annotation-absent`, which both yield `derivedPaths: []`), the distinction + is carried as its own field and is **never** re-derived from the payload. + +Types are grouped by which side of the boundary owns them: + +| Group | Owner | Sections | +|---|---|---| +| Generator inputs | `packages/adapters/catalog-backstage/` | §1–§3 | +| Generator internal | `packages/adapters/catalog-backstage/` | §4–§8 | +| The wire artifact | Neither — it *is* the boundary | §9–§10 | +| Consumer | `packages/catalog-envelope/` | §11–§15 | +| Oracle and gate freezes | Neither package; `specs/` and evidence | §16–§17 | + +--- + +## §1. `InputManifest` + +The generator's sole declaration of what it may read. Adopted from +`specs/009-catalog-binding-viability/contracts/input-manifest.md` §1 without +shape change. + +```text +InputManifest { + manifestSchemaVersion: "1" + requestedSnapshotSchemaVersion: "1" + requiredCapabilities: readonly ["pathOwnership"] + repository: ManifestRepository + sources: readonly ManifestSource[] +} + +ManifestRepository { + id: string // normalized `github.com//`, lowercase + revision: string // 40 lowercase hex characters +} + +ManifestSource { + path: string // repo-relative POSIX, validated per contracts/input-manifest.md §4.1 + digestAlgorithm: "sha256" + digest: string // 64 lowercase hex characters +} +``` + +**The schema is closed.** An unrecognized top-level field anywhere in the +manifest JSON is itself a rejection — never silently ignored as a +forward-compatible passthrough. This is `input-manifest.md` §1's rule, adopted +unchanged. + +**`path` is not validated by its label.** Calling a field "repo-relative +POSIX" is not a check. Every `path` passes **two stages, in order**: a purely +lexical rejection performed before the filesystem is touched, then a confined +`realpath` resolution that must still land beneath the verified checkout root. +Both are specified at `input-manifest.md` §4.1 and adopted unchanged. + +--- + +## §2. `RepositoryIdentityCheck` + +```text +RepositoryIdentityCheck { + manifestRepositoryId: string + manifestRevision: string + observedRemoteRaw: string // from `git remote get-url origin` + observedRepositoryId: string | "invalid" // after normalization + observedHead: string // from `git rev-parse HEAD` + outcome: "match" | "repository-mismatch" +} +``` + +Both observed values are read via **separate git tooling**, never by +re-reading the manifest under test. A partial match — revision agrees but +repository id does not, or the reverse — is `"repository-mismatch"`, not a +partial success. + +**Bounded warrant.** This confirms the manifest agrees with the checkout's own +**locally-configured** git state. It is **not** a network-verified provenance +check, and no artifact may describe it as one. + +--- + +## §3. `DescriptorDocument` + +One YAML document. A single file may hold several, so a document is addressed +by `(sourcePath, documentIndexInFile)` and never by path alone. + +```text +DescriptorDocument { + sourcePath: string + documentIndexInFile: integer // 0-based + parseOutcome: "parsed" | "duplicate-yaml-key" | "yaml-parse-error" + rawKind: unknown // pre-validation; type not assumed + rawMetadata: unknown // pre-validation; type not assumed +} +``` + +**`duplicate-yaml-key` and `yaml-parse-error` are distinct.** A document that +fails to parse for a YAML syntax reason *other than* a duplicate key must not +be reported under the duplicate-key outcome. They map to two distinct trigger +classes in §8. + +**`rawKind` / `rawMetadata` are deliberately `unknown`.** They hold whatever +the YAML node contained, before any admissibility or shape check. Typing them +optimistically would defeat §4, whose whole purpose is to decide whether they +are usable at all. + +--- + +## §4. `AdmissibilityResult` — new for this feature + +**Normative source**: +[ADR-0015](../../docs/adr/0015-treat-descriptor-admissibility-as-a-precondition-of-canonicalization.md). +This type has **no counterpart in spike 009**. + +```text +AdmissibilityResult { + admissible: boolean + failedFields: readonly AdmissibilityField[] // empty iff admissible +} + +AdmissibilityField = "kind" | "metadata.name" | "metadata.namespace" | "spec.type" +``` + +**Ordering rule, and why it is load-bearing.** This check runs **before** +canonicalization (§5). ADR-0015's decision is that admissibility is a +*precondition of* canonicalization, not a sibling check. The concrete +consequence: an inadmissible descriptor never acquires a `canonicalId`, so it +can never participate in a `duplicate-canonical-id` determination and can never +be reported under that trigger instead of its own. + +**Warrant, stated precisely.** The four-field validator table is pinned to +Backstage commit `1121a4facd9e321179d0402c3f355e4a649e84d9`. What is warranted +is **what that predicate returns when invoked**. It is **not** warranted, and +must not be written anywhere, that a Backstage deployment installs the policy, +that a catalog backend rejects such a descriptor, or that "Backstage requires" +these fields. + +**Failure semantics.** `admissible: false` raises the +`inadmissible-descriptor` trigger (§8) — a **fatal, whole-operation** trigger, +never a per-entity skip. This is ADR-0015's Condition of Acceptance 2, and +carrying it onto the atomic surfaces is the reason this feature exists as +ADR-0015's follow-up. + +--- + +## §5. `CanonicalEntityIdentity` + +Adopted from `entity-identity.md` §1 without change. + +```text +CanonicalEntityIdentity { + rawKind: string + rawNamespace: string | undefined // undefined when metadata.namespace omitted + rawName: string + canonicalId: string // `${kind}:${namespace}/${name}` lowercased WHOLE + allRefs: readonly string[] // non-empty; canonicalId is always a member +} +``` + +Two canonicalization steps, in order: + +1. If `metadata.namespace` is omitted, `namespace := "default"`. +2. `canonicalId := \`${kind}:${namespace}/${name}\`.toLowerCase()` — the + **entire** string is lowercased, not merely a prefix. + +**`allRefs` minimum, and what it does not decide.** `allRefs` is required to +be present and **non-empty** by the envelope's own validation step (§13, step +2), so the generator emits `[canonicalId]` at minimum. That is a **shape** +consequence of the envelope contract and is **not** an answer to how aliases +are sourced in production. + +`[NEEDS CLARIFICATION: How, if at all, is allRefs populated beyond the primary canonicalId in production? Spike 009 sourced alias refs from synthetic fixtures only (entity-identity.md §2); no real-corpus entity carries one; Backstage defines no standard alias field; no ADR decides it. Consequence: it is unknown whether duplicate-canonical-ref is reachable outside synthetic fixtures, so any coverage claim for that trigger must state whether it was exercised synthetically. Carried forward from spec.md unresolved.]` + +**Case-sensitivity boundary that must not move.** Canonicalization to +lowercase happens **only** at the generator boundary. It never changes +`packages/core/src/affects/**`'s existing case-sensitive (`nocase: false`) +matcher semantics (`entity-identity.md` §5). This feature changes nothing in +that directory. + +--- + +## §6. `OwnedPathsAnnotation` + +Adopted from `owned-paths-annotation.md` §1 without change. Five ordered +steps; each failure carries its **own distinct reason**. + +```text +OwnedPathsAnnotation { + annotationPresent: boolean // explicit discriminant, never inferred from `undefined` + rawNodeIsString: boolean | undefined + jsonParseOutcome: "parsed" | "parse-error" | "not-a-string" | undefined + shapeOutcome: "array-of-strings" | "wrong-shape" | undefined + rejectionReason: AnnotationRejectionReason | undefined +} + +AnnotationRejectionReason = + | "annotation-value-not-a-string" + | "parse-error" + | "wrong-shape" +``` + +**Step 2 is the one that is easy to omit and expensive to omit.** The +annotation node must be checked as a YAML **string scalar on the raw node**, +*before* `JSON.parse` is reached. `JSON.parse` coerces a non-string argument +via `ToString`, so the YAML sequence `["[]"]` would be stringified to `"[]"`, +parse cleanly as an empty array, and be **misclassified as `explicit-empty`**. +The TypeScript signature of `JSON.parse` provides no runtime protection here; +only the explicit `typeof rawNode === "string"` pre-parse check does. + +**Empty-string elements are not `explicit-empty`.** `[""]` and +`["", "packages/**"]` are non-empty arrays whose element fails the glob +dialect's rule 1 (`"empty"`), which — per §8 — aborts the whole operation. +`explicit-empty` is the decoded value `[]` exactly. + +--- + +## §7. `RestrictedGlobPattern` and `OwnershipState` + +### 7.1 `RestrictedGlobPattern` + +Adopted from `glob-dialect.md` §3. The validator applies **fifteen ordered +rules**, stopping at the **first** rule that matches, so a pattern violating +several rules always reports the same one reason. + +```text +RestrictedGlobPattern { + raw: string + outcome: GlobOutcome +} + +GlobOutcome = + | "accepted" // rule 15, compile succeeded + | "empty" // rule 1 + | "leading-slash" // rule 2 + | "absolute-or-drive-or-unc" // rule 3 + | "backslash" // rule 4 + | "nul-or-control-char" // rule 5 + | "brace" // rule 6 + | "bracket" // rule 7 + | "parenthesis" // rule 8 + | "comma" // rule 9 + | "leading-bang" // rule 10 + | "traversal-segment" // rule 11 + | "empty-segment" // rule 12 + | "disallowed-character" // rule 13 + | "malformed-double-star" // rule 14 + | "invalid-glob-compile-failure" // rule 15, defensive backstop +``` + +**Rule 15 is a backstop, and its non-occurrence is conformant.** +`glob-dialect.md` §3 rule 15 describes `"invalid-glob-compile-failure"` as +"expected to never occur in practice, given rules 1–14's exhaustiveness; +present only as a defensive backstop". Spec SC-007 accordingly requires rules +**1–14** each to be exercised and states that a run which never produces rule +15's outcome **is conformant and MUST NOT be reported as a coverage gap**. +Fifteen rules; fourteen required exercises. Do not conflate the two numbers. + +**Engine and options are frozen.** `picomatch` with +`{ dot: false, nocase: false, nonegate: true }`. The version recorded in the +envelope is **read from the resolved dependency**, not transcribed — see +`research.md` R3, which verified the current resolution as `picomatch@4.0.5` +at `bun.lock` line 165. Each accepted pattern is compiled **exactly once per +derivation run**, never once per match check. + +### 7.2 `OwnershipState` + +Exactly three values; there is no fourth. + +```text +OwnershipState = "explicit-paths" | "explicit-empty" | "annotation-absent" +``` + +| State | Condition | `derivedPaths` | +|---|---|---| +| `explicit-paths` | Annotation present, decodes and validates, resulting array **non-empty** after every element passes the glob validator | Sorted by `compareCodeUnits`, deduplicated, non-empty | +| `explicit-empty` | Annotation present, is a string scalar, and **decodes** to an array of length zero. A **decoded-value** check, never a raw-string equality check — `'[]'`, `'[ ]'`, `'[\n]'` all qualify identically | `[]` | +| `annotation-absent` | Annotation key wholly absent, decided by `annotationPresent === false` | `[]` | + +**Non-conflation rule.** `explicit-empty` and `annotation-absent` both yield +`[]`. They MUST NOT be treated as equivalent anywhere in the envelope or in +any evidence: each entity carries the discriminator as its own field, and the +distinction is never inferred from `derivedPaths`. + +--- + +## §8. `AtomicFailureRecord` — **fifteen** trigger classes + +```text +AtomicFailureRecord { + triggerClass: TriggerClass + detail: string // human-readable; never load-bearing + sourcePath: string | undefined + documentIndex: integer | undefined +} +``` + +```text +TriggerClass = + | "duplicate-canonical-id" + | "duplicate-canonical-ref" + | "duplicate-yaml-key" + | "invalid-yaml-syntax" + | "invalid-manifest-shape" + | "invalid-annotation-shape" + | "invalid-annotation-parse" + | "invalid-pattern" + | "unsupported-manifest-version" + | "unsupported-snapshot-version" + | "unsupported-capability" + | "repository-mismatch" + | "incomplete-required-source" + | "inadmissible-descriptor" // ← added for this feature + | "other-invalid-input" // ← deliberate always-present backstop +``` + +**The count, and the trap.** +`specs/009-catalog-binding-viability/contracts/atomic-fail-closed.md` §4 +states that every trigger class "MUST be one of exactly these **fourteen** +values" and lists them at lines 52–67. `inadmissible-descriptor` appears +**nowhere** under `specs/009-catalog-binding-viability/`; ADR-0015 is its only +source. Spec FR-035 states that fourteen "is correct for spike 009 and **wrong +for this feature**; it MUST NOT be copied across." The production count is +**fifteen**. Any artifact of this feature that says fourteen is wrong. + +**The consequence is identical for every trigger.** Whichever one fires — a +named class or the `other-invalid-input` backstop — the entire run aborts with +non-zero status and produces **no usable partial snapshot**, including for +entities that would otherwise have validated cleanly in the same run. The +single most likely implementation mistake this exists to foreclose is *"skip +the bad entity and keep going"*. + +**Four of the fifteen are request-level, not entity-level.** +`unsupported-manifest-version`, `unsupported-snapshot-version`, +`unsupported-capability`, and `incomplete-required-source` are properties of +the manifest/generation request as a whole, and abort **before any entity's +paths are derived**. The first three come from `input-manifest.md` §2's table; +the fourth from §4. `atomic-fail-closed.md` §5 groups them as the four +manifest-request-level rejections. + +**Overlap is not collision.** Two entities with **distinct** canonical ids +whose `owned-paths` both include the same pattern MUST both derive +successfully; a changed file matching that pattern is owned by **every** +matching entity simultaneously. This is the "no exclusive winner" rule +(`entity-identity.md` §4) and it must be **positively demonstrated**, never +inferred from the absence of a rejection. + +--- + +## §9. `SnapshotEnvelope` — the wire artifact + +**Nine top-level fields.** The generator writes this and **nothing else** +(ADR-0020 clause 7: "The generator writes the envelope and nothing else."). + +```text +SnapshotEnvelope { + schemaVersion: "1" + repository: { id: string, revision: string } + generatorVersion: string + globDialect: { engine: "picomatch", version: string, options: GlobOptions } + capabilities: readonly ["pathOwnership"] + completeness: { wholeCatalog: boolean, identityOnly: boolean } + sources: readonly EnvelopeSource[] + entities: readonly SnapshotEntityRecord[] + digest: string // 64 lowercase hex +} + +GlobOptions { dot: false, nocase: false, nonegate: true } +EnvelopeSource { path: string, digestAlgorithm: "sha256", digest: string } +``` + +**This is a new, separate artifact.** It is **never** added as a field on the +existing `CatalogSnapshot` / `CatalogSnapshotEntity` types at +`packages/core/src/affects/catalog.ts`, and it never becomes part of the +published ADR schema. Those core types were read in this worktree and are: + +```text +CatalogSnapshotEntity { id: EntityId, refs?: readonly string[], paths?: readonly string[] } +CatalogSnapshot { entities: readonly CatalogSnapshotEntity[] } +CatalogPort { resolveEntity(ref), entitiesForPaths(paths), snapshot() } +``` + +They are **unchanged** by this feature (spec FR-020). + +**One envelope per generation pass; never merged.** Single-repository only is +an absolute constraint. Generation never produces a federated or +multi-repository snapshot. + +--- + +## §10. `SnapshotEntityRecord` — **exactly five fields** + +```text +SnapshotEntityRecord { + identity: { canonicalId: string, allRefs: readonly string[] } + ownershipState: OwnershipState + derivedPaths: readonly string[] + sourceDocument: { sourcePath: string, documentIndexInFile: integer } + provenance: string +} +``` + +This is the **serialized projection** of the generator's internal entity +record, not that record field-for-field. `snapshot-envelope.md` §1 fixes the +shape and its rationale: the identity projection carries `{ canonicalId, +allRefs }` **only**, because the pre-lowercase authoring inputs are already +fully captured by those two fields. + +**A flatter shape is forbidden.** Spec FR-039 and `snapshot-envelope.md` §1 +both require exactly this nested five-field shape — **never** a flatter +`canonicalId` / `refs` / `paths` triple, and never the full internal objects — +so that the on-disk envelope and the declared type are one identical defined +type rather than two independently-drifting shapes for the same record. + +--- + +## §11. `EnvelopeValidationResult` — consumer side, **five ordered steps** + +Owned by `packages/catalog-envelope/`. Adopted from `snapshot-envelope.md` §2 +without change. + +```text +EnvelopeValidationResult { + outcome: "valid" | "rejected" + failedStep: 1 | 2 | 3 | 4 | 5 | undefined + reason: EnvelopeRejectionReason | undefined +} + +EnvelopeRejectionReason = + | "invalid-json" // step 1 + | "missing-or-wrong-required-field" // step 2 + | "unrecognized-schema-or-dialect-or-capability" // step 3 + | "missing-source-digest" // step 4 + | "identity-only-true" // step 5 +``` + +The five steps, in **exactly** this order, rejecting non-zero at the first +failure and naming the specific reason: + +| Step | Check | Note that is easy to get wrong | +|---|---|---| +| 1 | Parses as JSON at all | — | +| 2 | Complete §9/§10 shape present with every field the correct JSON type at **every** nesting level | A `missing-source-digest` envelope does **not** fail here — its `sources` entry is otherwise well-formed. It is caught at step 4. | +| 3 | Frozen matcher contract validated by **exact value**: `schemaVersion === "1"`; `globDialect` deep-equals the frozen object; `capabilities` deep-equals the exact tuple `["pathOwnership"]` | A `globDialect.version`-only check is **insufficient**. A per-entry-membership-only check on `capabilities` is **insufficient**. | +| 4 | Every `sources[]` entry has a **present**, correctly-typed `digest` that matches its listed path's actual bytes | Omission and mismatch both fail here. | +| 5 | `completeness.identityOnly === false` | Determined **solely** from this boolean — **never** by scanning the entity list's `ownershipState` distribution. | + +**The contrast case that must pass.** An envelope whose entities are **all** +`annotation-absent`, with `completeness.identityOnly: false`, is **not** +rejected. Absent annotations are a valid, expected state. Rejecting on the +ownership-state distribution is a bug, and it is the specific bug step 5's +wording exists to prevent. + +**Only after steps 1–5 all pass** does the consumer proceed to §12–§14. + +--- + +## §12. `DigestCheckResult` + +```text +DigestCheckResult { + declaredDigest: string + recomputedDigest: string + outcome: "match" | "digest-mismatch" +} +``` + +Canonicalization is §9's object **including `schemaVersion` and every other +field, excluding only `digest` itself**: keys recursively sorted at every +nesting level by `compareCodeUnits`, arrays serialized in **declaration order** +(never re-sorted), compact separators, `undefined` fields omitted. SHA-256 over +the UTF-8 bytes; 64 lowercase hex characters. + +**Implementation instruction.** Use `canonicalStringify` exported from +`@adrkit/core` (`packages/core/src/fingerprint/index.ts` line 16, re-exported +at `packages/core/src/index.ts` line 24). Do **not** write a second one, and do +**not** import the different same-named function at +`packages/evaluator/src/report/serialize.ts` line 38. + +**Guarantee scope that must travel with every mention of this check.** The +digest proves **accidental-corruption and naive-mutation detection only**. It +does **not** resist an adversary who mutates content and recomputes the same +digest. A cryptographically-signed tamper-evidence mechanism is an explicitly +open question this feature does not attempt. Never overclaim adversarial +tamper-resistance. + +**And a second scope statement, from ADR-0020 clause 5.** A populated, +digest-verified envelope proves **integrity, not correctness** — "a +semantically wrong envelope can carry a perfectly valid self-digest." +Correctness is established only by §17's expected-vs-observed comparison. + +--- + +## §13. `StalenessAndIdentityCheckResult` + +```text +StalenessAndIdentityCheckResult { + expectedRepositoryId: string | undefined + expectedRevision: string | undefined + outcome: "ok" | "stale-revision" | "repository-identity-mismatch" +} +``` + +**"Stale" means exact inequality, never a chronological judgement.** Commit +SHAs are opaque identifiers with no ordering available without separate git +ancestry data, which is out of scope. When configured with an expected-current +revision, an envelope declaring **any** other revision for that repository id +is rejected as stale. + +**Isolation from the digest check.** A staleness or identity fixture MUST have +its own digest **recomputed over its own actual mutated content**, so it passes +§12 cleanly and the rejection is attributable specifically to staleness or +identity — never to a coincidental digest failure. + +--- + +## §14. `RepositoryIsolationQuery` — acceptance, not rejection + +```text +RepositoryIsolationQuery { + loadedEnvelopes: readonly SnapshotEnvelope[] // each independently valid + scopedRepositoryId: string + returnedEntities: readonly SnapshotEntityRecord[] +} +``` + +A tool legitimately holding two or more independently-generated, +individually-valid single-repository envelopes MUST NOT let a query scoped to +one repository id return any entity originating from another. **Neither +envelope is rejected**; both remain valid. Isolation is a property of the +**query**, not an error condition. + +**The line this draws.** §13's `repository-identity-mismatch` is +mismatch-and-reject, for a consumer that expected exactly **one** repository. +§14 is filter-and-isolate, for a consumer deliberately querying across +several, **each individually expected**. Conflating them turns a legitimate +multi-repository index into a rejection, or a mismatch into a silent filter. + +--- + +## §15. `DerivedCatalogSnapshot` + +```text +DerivedCatalogSnapshot { + snapshot: CatalogSnapshot // the EXISTING core type, unmodified + derivedFrom: { repositoryId: string, revision: string, envelopeDigest: string } +} +``` + +**Derivation is gated.** A `CatalogSnapshot`-shaped artifact is derived from +the envelope **only after** that envelope independently passes every §11 +validation step and §12's digest check (ADR-0020 clause 7; ADR-0012's +requirement that any persisted `CatalogSnapshot` require a validated +interchange file first). An adapter's raw output MUST NOT be handed to core +directly and unvalidated, under any composition arrangement. + +**The mapping is lossy by design and must be documented as such.** +`CatalogSnapshotEntity` is `{ id, refs?, paths? }` — it has **no** +`ownershipState` field. So `explicit-empty` and `annotation-absent` both map +to an entity with an empty (or absent) `paths`, and the distinction the +envelope preserves is **not** representable in the core type. The deriver +therefore keeps the distinction on the envelope side and does not attempt to +smuggle it into `CatalogSnapshot`. Changing `CatalogSnapshot` to carry it is +**out of scope** (spec FR-020). + +--- + +## §16. `FrozenExpectationSet` — the re-frozen oracle + +Not a package type. This is the shape of the artifact Barrier B's first two +steps produce. + +```text +FrozenExpectationSet { + frozenAt: string // ISO-8601 + derivedPathPatterns: readonly string[] // sorted by compareCodeUnits ← the correction + expectedByEntity: readonly { canonicalId: string, expectedPaths: readonly string[] }[] + contentHash: string // over the canonical form of this set + auditRecord: IndependentAuditRecord +} + +IndependentAuditRecord { + auditedAt: string + auditorIsIndependentOfAuthor: true + observedHash: string // recomputed, not copied + verdict: "pass" | "fail" +} +``` + +**What "fresh" means here, and why a stale copy is the specific hazard.** +ADR-0012 gate 3 is `Unmet`: "The oracle exists but carries a known-wrong +expected result and must be re-frozen." ADR-0020 clause 6 requires a **fresh** +T014 → T014a cycle — correct the `derivedPathPatterns` ordering, re-freeze, +re-hash, obtain a **new** independent pre-output audit — before producing +generator output. ADR-0015's Condition of Acceptance 1 records that the spike's +evidence bundle is **untracked / scratch-only**, so "nothing in the repository +will stop someone reusing a stale copy — this clause is the only control." + +**The correction itself.** `derivedPathPatterns` was frozen in **input +order**; it must be frozen in **`compareCodeUnits`-sorted** order, matching +§7.2's `explicit-paths` requirement that derived paths are sorted and +deduplicated. + +**`auditRecord.observedHash` is recomputed, never copied.** An audit that +transcribes the author's declared hash has verified nothing. + +--- + +## §17. `AcceptCorpusFreeze` — the clause-5 gate artifact + +```text +AcceptCorpusFreeze { + corpusRef: { repository: string, commit: string } + selectionBasis: string // recorded BEFORE selection is acted on + size: integer // recorded in the same step, not afterwards + overlay: readonly { sourcePath: string, documentIndexInFile: integer, + annotationValue: string }[] + expectedPaths: readonly { canonicalId: string, expectedPaths: readonly string[] }[] + contentHash: string + auditRecord: AdequacyAuditRecord +} + +AdequacyAuditRecord extends IndependentAuditRecord { + adequacyFinding: "adequate-for-the-claim" | "inadequate" // explicit; required + adequacyReason: string +} +``` + +**Every field above is required by ADR-0020 clause 5, and two are the ones an +implementer is most likely to drop.** + +- `selectionBasis` and `size` are "fixed and recorded in that same cycle, **not + chosen afterwards**". Selecting a corpus and then writing down why is + precisely the failure mode this forecloses. +- `adequacyFinding` is required explicitly: "that audit must record an explicit + finding that the corpus is adequate for the claim being made; an audit that + passes on integrity without reaching adequacy **does not satisfy this + clause**." + +**No minimum entity count is fixed.** ADR-0020 clause 5 declines to name one +deliberately, because ADR-0012 holds that production limits are "**not** +guessed now; they must be ratified from evidence". Adequacy is "a recorded +judgement by an independent reviewer against the frozen corpus, never a number +invented by this record." **This plan does not invent one either.** + +**Input provenance, and the honesty rule attached to it.** The descriptors are +real, authored upstream, and otherwise **unmodified**; the annotation overlay +is **maintainer-authored**. Per ADR-0014's honesty rules and ADR-0020 clause 5, +maintainer reference verification is **rung 2** and MUST NOT be described as +external, third-party, or community adoption — **only the corpus data is +third-party, never the validation**. + +**What the construction buys and does not buy** (ADR-0020 clause 5, carried so +it is not overstated downstream): it exercises ownership derivation against +real descriptor structure and real field shapes, at whatever scale the audited +corpus fixes. It gates **technical compatibility only**. It does **not** +evidence that the mapping reflects anyone's actual ownership, that anyone else +wants the annotation, or that adoption risk has fallen. An +all-`annotation-absent` corpus satisfies none of it — it yields a populated +envelope while exercising no derivation at all. Wholly synthetic entities +satisfy none of it either — the spike already proved that path. + +**Selection is constrained by facts already known.** Clause 5 requires the +accept corpus to be admissible under ADR-0015 and free of duplicate canonical +ids. `research.md` R14 records the invalid-name and placeholder-collision +populations in both pinned corpora, with their counts and their qualifications. +Selecting without regard to them would fail the gate on inputs known in advance +to fail; how they were handled is part of what the audit inspects. + +--- + +## Cross-cutting: which package owns which type + +| Type | `catalog-backstage` | `catalog-envelope` | Neither | +|---|---|---|---| +| §1 `InputManifest`, §2 `RepositoryIdentityCheck`, §3 `DescriptorDocument` | ✅ | — | — | +| §4 `AdmissibilityResult`, §5 `CanonicalEntityIdentity`, §6 `OwnedPathsAnnotation`, §7 `RestrictedGlobPattern` / `OwnershipState`, §8 `AtomicFailureRecord` | ✅ | — | — | +| §9 `SnapshotEnvelope`, §10 `SnapshotEntityRecord` | writes | reads | the boundary itself | +| §11–§15 consumer types | — | ✅ | — | +| §16 `FrozenExpectationSet`, §17 `AcceptCorpusFreeze` | — | — | ✅ evidence, not code | + +**The boundary rule that this table encodes.** The two packages depend on +neither each other nor anything but the envelope file (spec FR-044). §9/§10 +appear in both columns because each package declares its **own** view of the +wire shape; they are validated to agree by the consumer's step-3 exact-value +check and by the digest, not by a shared type import. + +**One duplication is therefore deliberate.** Both packages carry a +declaration of the envelope shape. That is not drift to be eliminated — it is +what makes the boundary a boundary. What must **not** diverge is the +serialized bytes, and that is enforced by §12's digest and by §11 step 3, not +by refactoring the two declarations into one shared package. diff --git a/specs/010-catalog-backstage/plan.md b/specs/010-catalog-backstage/plan.md new file mode 100644 index 00000000..b5a74732 --- /dev/null +++ b/specs/010-catalog-backstage/plan.md @@ -0,0 +1,696 @@ +# Implementation Plan: Backstage Catalog Adapter — Offline Owned-Paths Snapshot Generator + +**Feature**: `010-catalog-backstage` +**Feature directory**: `specs/010-catalog-backstage/` +**Worktree branch**: `mbeacom-supreme-guacamole` (a linked worktree of `mbeacom/adrkit`) +**Date**: 2026-08-04 +**Spec**: [spec.md](./spec.md) +**Input**: Feature specification at `specs/010-catalog-backstage/spec.md`, with +[research.md](./research.md) (R1–R14), [data-model.md](./data-model.md), and +[contracts/](./contracts/) as its completed Phase 0 / Phase 1 companions. + +**Authorization**: ADR-0020 (accepted) authorizes **the work**, not the release. Nothing in this +plan schedules, implies, or depends on a release. ADR-0020 clause 9 defers release; ADR-0012 +gate 3 is open and gate 4 is unmet and not yet testable (`research.md` R9). + +--- + +## Summary + +Build an offline generator that reads one explicit manifest plus the descriptor files that +manifest names, decides each descriptor's admissibility against a pinned Backstage predicate +surface, canonicalizes entity identity, derives owned-path patterns from the +`adrkit.io/owned-paths` annotation alone, and writes exactly one versioned `SnapshotEnvelope` — +or aborts the whole operation, atomically and fail-closed, on any of **fifteen** trigger classes. +Build alongside it a separate consumer package that validates such an envelope and derives a +`CatalogSnapshot`-shaped artifact from it, and which depends on the generator in no way at all. + +The technical approach is fixed almost entirely by prior decisions, and this plan adopts them +rather than re-deciding them: package placement (`research.md` R1), canonicalization reuse (R2), +glob engine provenance (R3), dependency direction (R7), YAML reading (R11), and the corpus pins +(R14). What this plan adds is **sequencing** — because the single hardest constraint on this +feature is not what to build but *what may not exist yet when a given piece is built*. + +That constraint is ADR-0020 clause 6's pre-output barrier, called **Barrier B** throughout this +plan. It is not a checklist item. It is the organizing structure of the build, and every phase +below states which side of it the phase sits on. + +**No evidence exists for this feature.** Nothing has been built, run, or measured. Every +behavioural statement in this document is a requirement on work not yet done. This is ADR-0014 +**rung 1** work throughout: maintainer verification, with only the corpus *data* being +third-party. It is never external, third-party, or community validation. + +--- + +## Technical Context + +**Language / Version**: TypeScript, developed on the Bun toolchain (Bun 1.3.14, from the root +`package.json` `packageManager` field). Published artifacts target Node `>=22` (root +`package.json` `engines.node`, and FR-051). + +**Primary Dependencies**: + +| Dependency | Declared | Resolved | Role | +| --- | --- | --- | --- | +| `picomatch` | `^4` (`bun.lock:47`) | `4.0.5` (`bun.lock:165`) | Glob compilation, `{ dot: false, nocase: false, nonegate: true }` (R3) | +| `yaml` | `latest` (`bun.lock:49`) | `2.9.0` (`bun.lock:187`) | `parseDocument`, `uniqueKeys` left at its default `true` (R11) | +| `@adrkit/core` | workspace | workspace | `canonicalStringify` and `compareCodeUnits` (R2, decision G1) | + +All three lockfile line numbers were read in this worktree. Both registry dependencies are +already present in the committed lockfile; **this feature adds no new registry surface.** + +Two provenance rules travel with these: + +- The `picomatch` version recorded in an envelope MUST be **read at runtime from the resolved + dependency**, never transcribed into a table. Spike 009's `glob-dialect.md` §1 pinned the + literal; the value is unchanged today, but the *provenance* is a production delta (R3). + Authority: `spec.md` assumption A6, plus the consumer's own exact-value deep-equal check — + `snapshot-envelope.md` §2 step 3 states a version-only check is insufficient. +- The `yaml` declaration is `latest`, so the resolved version is pinned only by the committed + lockfile. Anything that re-resolves the lockfile can move `2.9.0`. Duplicate-key detection + depends on `uniqueKeys` defaulting to `true`; the default MUST never be set to `false`, and a + version move MUST be treated as a change to the parse contract, not as housekeeping. + +**Storage**: Filesystem only. One envelope JSON file per successful run, at a caller-specified +path. No database, no index, no cache. Consistent with Constitution Principle I: anything +derived is disposable and rebuildable from git. + +**Testing**: `bun test` (root `package.json` `test` script), under ADR-0016's observed-failing- +first discipline (`research.md` R8). A check only ever observed passing is not coverage. + +**Target Platform**: Node `>=22` for the published artifact shape; Bun for development and CI. +Runtime posture is offline, credential-free, and service-free (FR-050, FR-052). + +**Project Type**: Two workspace library packages — one adapter (a standalone generator invoked +directly by name, with **no** dynamic loader, per ADR-0013 and FR-002) and one non-adapter +consumer library. No CLI surface wiring in this feature. + +**Performance Goals**: **None, deliberately.** Per FR-055 and ADR-0012, production limits "are +not guessed now; they must be ratified from evidence." No throughput target, no latency target, +and no minimum or maximum entity count is set by this plan. Recording a guess here would create +exactly the unratified default ADR-0012 forbids. + +**Constraints**: + +- Offline at generation time; the input boundary is the manifest, the manifest-listed and + digest-verified descriptor paths, and two git-identity values read via subprocess (FR-013, R10). +- No recursive directory walking and no glob-based discovery of inputs (`input-manifest.md` §5). +- Byte-identical output across repeated runs on identical inputs (FR-042). The envelope's field + list carries no clock-derived field, which is what makes this achievable. +- Whole-operation atomic fail-closed over **fifteen** trigger classes (FR-034, FR-035). +- Single repository per operation (FR-007). +- ADR-0014 rung 1 only; no release scheduling (FR-062, FR-063). + +**Scale / Scope**: Bounded by the manifest, not by the tree. For orientation only, the corpus +pins in `research.md` R14 are `community-plugins` at +`92e9e4e09c76cc57f3475029b73e5ec84498a459` — **156 descriptor files** carrying **167 entity +documents** — and `rhdh-plugins` at `3b355ddfedb23c6656bd9effc8510f9926b765c1` — **38 descriptor +files** carrying **39 entity documents** (the 38/39 figure holds only under exact +`catalog-info.yaml` basename matching). **File counts and document counts are different +populations** and must never be reported interchangeably. These are reference figures for +fixture design; they are not scale targets. + +--- + +## Constitution Check + +Evaluated against `.specify/memory/constitution.md` v1.0.2. This is the **pre-design** pass; the +post-design re-evaluation follows the Project Structure section. + +### Principle I — Git Is the Source of Truth + +**PASS, and load-bearing.** + +The generator writes only an envelope to a caller-specified path; it never mutates a record under +`docs/adr/**`. The envelope is a derived, disposable projection, rebuildable from the manifest +and the descriptor files — precisely the category Principle I permits. + +Principle I is doing real work here rather than being merely satisfied. ADR-0015's Condition of +Acceptance 1 identifies spike 009's untracked scratch evidence bundle as a hazard. Barrier B's +second enforcement mechanism (hash re-derivation in CI, R5) is only possible if the frozen +artifacts are **tracked in git**. So Principle I is the reason the freeze artifacts in Phase B +live in the repository rather than in a scratch directory. + +### Principle II — Clean Clone Builds Green + +**PASS, with one obligation named rather than assumed.** + +Both new packages are matched by existing `workspaces` globs and are therefore picked up by +`bun run --filter='*' …` and by `bun test` with **no workflow change** (R10). The existing +`clean-clone-builds` job (`.github/workflows/ci.yml:12`, whose steps run frozen install, +typecheck, build, lint, test, and `check:deps` at lines 23–40) covers them as-is. No new registry +dependency is introduced, so the only permitted public-registry access — during +`bun install --frozen-lockfile` — is unchanged. + +The obligation: SC-016 requires network access to be **actively denied** during the generator +run, not merely unexercised. "No calls were observed" is a weaker claim than "calls were +impossible." Meeting the stronger form is Phase G work and is tracked there, not waved through +here. + +### Principle III — Core Depends on No Adapter + +**PASS.** This is the Principle the design interacts with most, in three directions. + +*Direction (a) — nothing outside adapters may depend on an adapter.* The consumer lives at +`packages/catalog-envelope/`, outside `packages/adapters/**`, and depends on `@adrkit/core` only +(FR-044). `isAdapterPackage()` (`scripts/check-deps.ts:92–94`) classifies purely by the +`packages/adapters/` path prefix, so the consumer is a non-adapter **by construction** — there is +no allowlisted carve-out granting it that status. `core-has-no-adapter-deps` is satisfied +structurally. + +*Direction (b) — the adapter's own dependency on core.* Decision **G1** (`research.md` R7) has +the adapter depend on `@adrkit/core` for canonicalization primitives. R7 recorded this as +"undesirable but permitted." Principle III's own text describes adapters as packages that "live +under `packages/adapters/*`, **depend on the core**, and are permitted to break on upstream +churn" — so G1 is the Principle's expected shape, not a concession. The residual concern is +versioning legibility, and it is handled by an explicit `"//versioning"` note following the +precedent already carried in `packages/adapters/spec-kit/package.json`. + +*Direction (c) — an apparent tension worth naming.* Principle III says adapter "discovery is by +runtime configuration." ADR-0013 and FR-002 forbid a dynamic loader outright; the generator is +invoked directly by name. Where the Constitution and an accepted ADR differ, the Constitution's +own precedence rule gives it to the ADR. This is recorded as a resolved precedence question, not +as a violation, and not as something to quietly ignore. + +The `isolated` linker in `bunfig.toml` is not changed by this feature. + +### Principle IV — Deterministic Before Probabilistic + +**PASS.** + +The generator is wholly deterministic: no model, no heuristic, no clock in the derivation path. +The envelope's field list (`data-model.md` §9) contains no temporal field, which is what makes +FR-042's byte-identical repeat runs achievable rather than aspirational. The only timestamps in +this feature are on *evidence* artifacts (`FrozenExpectationSet.frozenAt`, +`AcceptCorpusFreeze.auditRecord.auditedAt`), which are records about the work, not outputs of it. + +`packages/core/src/affects/**` is untouched (FR-004), so the existing `resolution-is-pure` +assertion stays green unchanged. + +One subtlety worth stating because it looks like a conflict and is not: Principle IV requires +that "a matcher whose backing source is missing resolves to inert with an informational finding — +never a fatal error," while this feature is aggressively fail-closed. These are different +subjects. Fail-closed governs the **generator**; inert-on-missing governs core's **`affects` +resolver**. When the consumer rejects an envelope, core sees a matcher with an absent backing +source and behaves exactly as before. Neither rule is bent. + +### Principle V — The Schema Is the Contract + +**PASS.** + +No change to `packages/core/src/schema/adr.schema.ts` or to `schema/adr.schema.json` (FR-004, +FR-005). The `schema-emit-matches` check is unaffected because nothing is emitted differently. + +Naming the misreading in advance: the two packages each declare the envelope's shape +independently (`data-model.md` cross-cutting table, and `contracts/package-boundary.md` §5). That +is a deliberate duplication and it is **not** a Principle V violation, because Principle V is +scoped to the typed *frontmatter* schema — the ADR record contract. The envelope is explicitly a +separate artifact that never joins that schema. The duplication is what makes the consumer's +structural validation a real check instead of a tautology; a shared type module would be an +import edge, and the import edge is exactly what FR-044 forbids. + +### Pre-design verdict + +**No violations.** Proceed to design. + +--- + +## Project Structure + +### Documentation (this feature) + +```text +specs/010-catalog-backstage/ +├── spec.md # committed; frozen for this phase +├── plan.md # this file +├── research.md # R1–R14; frozen +├── data-model.md # §1–§17; frozen +├── quickstart.md # validation/run guide +├── checklists/ +│ └── requirements.md # frozen +├── contracts/ +│ ├── README.md # the register: adopted / delta / excluded / new +│ ├── atomic-fail-closed.md # delta D1: fourteen → fifteen +│ ├── admissibility.md # new (ADR-0015 postdates spike 009) +│ └── package-boundary.md # new (spike 009 built no packages) +└── evidence/ # created in Phase B; tracked, hash-checked + ├── frozen-expectations/ # FrozenExpectationSet (data-model.md §16) + └── accept-corpus-freeze/ # AcceptCorpusFreeze (data-model.md §17) +``` + +Five spike-009 contracts are **carried forward by reference, not copied**: `glob-dialect.md`, +`owned-paths-annotation.md`, `entity-identity.md`, `input-manifest.md`, and +`snapshot-envelope.md`, all under `specs/009-catalog-binding-viability/contracts/` (that +directory holds exactly 11 files, counted in this worktree). `contracts/README.md` §2 is the +register of what is adopted unchanged, adopted with delta, and excluded. Two further spike +sections remain relevant and are cited from their original locations rather than restated: +`scale-and-security-measurement.md` §5 (network denial) and `input-manifest.md` §3.1 (the +standalone scratch repository requirement). + +The `evidence/` placement is a decision made by this plan. Its authority is `research.md` R5 +mechanism 2, which requires CI to re-derive the freeze hashes — which requires the artifacts to +be tracked — together with ADR-0015 Condition of Acceptance 1, which identifies untracked scratch +evidence as the hazard being corrected. No prior document fixes the path; this one does. + +### Source code (repository root) + +```text +packages/ +├── adapters/ +│ ├── spec-kit/ # existing; unchanged +│ └── catalog-backstage/ # NEW — the offline generator (adapter) +│ ├── package.json # independent versioning + "//versioning" note (ADR-0007) +│ └── src/ +│ ├── manifest/ # manifest schema, two-stage path validation, digests +│ ├── repository/ # git identity + revision, exact string equality +│ ├── descriptor/ # parseDocument reader; uniqueKeys default true +│ ├── admissibility/ # ADR-0015's four field validators +│ ├── identity/ # two-step canonicalization +│ ├── ownership/ # annotation decode; three ownership states +│ ├── glob/ # fifteen ordered rules; compile once per run +│ ├── envelope/ # assembly + digest via @adrkit/core (G1) +│ └── failure/ # the fifteen fatal trigger classes +└── catalog-envelope/ # NEW — the consumer (NOT an adapter) + ├── package.json + └── src/ + ├── validate/ # five ordered validation steps + ├── digest/ # recomputation; integrity-scoped claims only + ├── identity/ # staleness (exact inequality) + repository isolation + └── snapshot/ # CatalogSnapshot-shaped derivation + +scripts/check-deps.ts # MODIFIED — allowlist entries for both new packages +.github/workflows/ci.yml # MODIFIED — freeze-hash drift check; clause-8 gate +``` + +Root `package.json` `workspaces` is `["packages/*", "packages/adapters/*"]`, which already +matches both new paths. **It is not changed.** A change there would be evidence the placement is +wrong. + +### Structure decision + +Two packages, no edge between them, the envelope file on disk as the entire interface. + +The generator is an adapter because its semver contract is with the pinned Backstage predicate +surface and it is permitted to break on upstream churn (ADR-0007). The consumer is not an +adapter, because a package outside `packages/adapters/**` is the only kind of package that core +or the CLI could ever be permitted to depend on later — and Principle III explicitly allows core +and CLI to depend on "their own workspace packages." That future is left open by placement and +is **out of scope here**: wiring the consumer into `@adrkit/cli` would require amending +`allowedDependenciesFor('@adrkit/cli')`, currently exactly `{'@adrkit/core', '@adrkit/evaluator'}` +(`scripts/check-deps.ts:107–115`). Do not amend it under this feature. + +Both packages need explicit `allowedDependenciesFor()` entries, and this is not optional +housekeeping: the function returns `undefined` for any package it has no entry for +(`scripts/check-deps.ts:151`), and the allowed-surface guard is then skipped entirely. A package +with no entry is **silently unconstrained** and passes `check:deps` regardless of what it +declares. Full detail in `contracts/package-boundary.md` §4. + +### Post-design Constitution re-check + +Re-evaluated against the structure above. **No violations.** + +- Principle I — unchanged; the `evidence/` decision strengthens it. +- Principle II — unchanged; both new package paths are covered by existing globs, so the clean + clone job needs no edit. The two CI edits listed are the freeze-drift check and the clause-8 + gate, neither of which weakens the clean-clone posture. +- Principle III — the structure makes the consumer a non-adapter by location and gives the + adapter exactly the core dependency Principle III describes. Strengthened, not strained. +- Principle IV — the module split keeps every derivation unit pure; the only impure edges are + the manifest/descriptor reads and the git-identity subprocess, both confined to + `manifest/` and `repository/`. +- Principle V — no schema file is touched. The deliberate two-sided envelope declaration is + restated above as a non-violation. + +--- + +## Barrier B — the pre-output barrier + +This is the organizing constraint of the build. It comes from ADR-0020 clause 6 and is defined +in `research.md` R4 and R5. It is restated here because a phase sequence that does not carry it +is not a plan for this feature. + +### What counts as generator output + +Per R4, **generator output** is: + +> a `SnapshotEnvelope` written or returned by the assembled generator, **or** any +> derived-ownership result computed for a descriptor-sourced entity — whether persisted, held in +> memory, or asserted in a test. + +The "held in memory, or asserted in a test" clause is the whole point. An in-memory ownership +derivation that is never written to disk is still generator output. So is one that exists only +inside a test assertion. + +### R4's reviewer-applicable distinguishing test + +> **Where does this test's expected value come from?** +> If it comes from a contract frozen in `specs/` or `docs/adr/`, the work is barrier-free. +> If it comes from — or could be silently adjusted to match — the oracle expectation set or the +> clause-5 accept-corpus expected paths, it is behind the barrier. + +The second limb matters more than the first. A test whose expected value *could be quietly +edited* to match whatever the generator produced is behind the barrier even if today it happens +to be hand-written, because the barrier exists to make that edit impossible rather than merely +discouraged. + +### Where the definition and the distinguishing test diverge, and how this plan resolves it + +They do diverge, at exactly one place: a whole-operation atomicity test over a mixed batch +(several valid entities plus one triggering entity). Its expected value — *exit non-zero, no +envelope* — comes from `contracts/atomic-fail-closed.md`, a frozen contract, so the +distinguishing test would call it barrier-free. But running it may compute in-memory ownership +for the valid entities before the abort, which the **definition** catches. + +**This plan takes the definition, and places all assembled-generator work behind the barrier +(Phase E).** The cost is low, because Barrier B is early and cheap relative to Phase E. No part +of this plan relies on the distinguishing test to release assembly work early. + +### The three enforcement mechanisms (R5) + +All three are required. None is sufficient alone. + +1. **Input absence.** No manifest ⇒ no corpus ⇒ no output. This is backed by + `input-manifest.md` §5, which forbids recursive walking and glob-based input discovery. The + generator physically cannot find a corpus to run against; there is nothing to *remember* not + to do. +2. **Hash match.** Each freeze records its own hashes; CI re-derives them and fails on drift. + This is what stops a frozen expectation from being edited after output exists. It requires the + freeze artifacts to be tracked in git, which is why they live under + `specs/010-catalog-backstage/evidence/`. +3. **Ordering of the comparison harness.** The harness that reads both generator output *and* + the frozen expectations is written **after** the freeze and its audit — never before. + ADR-0020 clause 5 requires two distinct steps, each recording its own hashes and its own + PASS/FAIL. A harness authored first collapses them into one step, and the second step's + PASS then inherits from the first instead of standing alone. + +### The clause-6 reach question, resolved + +**Resolved 2026-08-04 by maintainer decision: the narrower reading applies.** ADR-0020 clause +6's bar on "generator output" does **not** reach unit-level execution of the adapter's pure +validators — glob dialect, annotation decode, admissibility, identity canonicalization — +against hand-authored fixtures whose expected values come from contracts frozen in `specs/` or +`docs/adr/`. It reaches the assembled generator's output. + +The reason the narrower reading is safe, stated so a reviewer can check it: clause 6 exists to +prevent **backfilling** — adjusting a frozen expectation to match output already seen. That +control has no purchase on a test whose expected value the oracle never sourced. R4's +distinguishing test is the operative rule: + +> *Where does this test's expected value come from?* If it comes from a contract frozen in +> `specs/` or `docs/adr/`, the work is barrier-free. If it comes from — or could be silently +> adjusted to match — the oracle expectation set or the clause-5 accept-corpus expected paths, +> it is behind the barrier. + +Two limits travel with this decision and are **not** relaxed by it. Where R4's definition and +its distinguishing test disagree — a whole-operation atomicity test over a mixed batch, whose +expected value is contract-sourced but which may compute in-memory ownership before aborting — +**this plan takes the definition**, and all assembled-generator work stays behind the barrier. +And the decision changes only Phase D's barrier side; every other phase, and all three of R5's +enforcement mechanisms, are unaffected. + +Consequence for scheduling: Phase D is barrier-free, so the adopted sequence is +`[A ∥ B] → [C ∥ D] → E → F → G`. + +--- + +## Phased build sequence + +Seven phases, in dependency order. Each states its side of Barrier B and the requirements and +success criteria it discharges. + +### Phase A — Workspace placement and dependency-boundary enforcement + +**Barrier side: BEFORE.** Barrier-free under both readings of clause 6. No generator exists, no +descriptor is read, no ownership is derived. + +Create both package skeletons at the paths in the Project Structure section; add explicit +`allowedDependenciesFor()` entries for both (`contracts/package-boundary.md` §2); record the +adapter's `"//versioning"` note; write package READMEs carrying rung-1 language and no forbidden +synonym. + +**Discharges**: FR-001, FR-002, FR-003, FR-005, FR-044 *(placement and direction half)*, FR-051, +FR-062, FR-063 · SC-015, SC-017. + +**Depends on**: nothing. + +### Phase B — Barrier B: fresh T014 → T014a cycle and clause-5 accept-corpus freeze + +**Barrier side: THIS PHASE IS THE BARRIER.** + +Re-run the T014 → T014a oracle cycle from scratch — spike 009's oracle carries a known-wrong +expected result and cannot be reused (`research.md` R9, ADR-0012 gate 3). Produce the corrected +`FrozenExpectationSet` (`data-model.md` §16) with `derivedPathPatterns` in `compareCodeUnits`- +sorted order and its own content hash. Freeze the accept corpus, its maintainer-authored +`adrkit.io/owned-paths` overlay, its expected path matches, and its selection basis and size +**in the same cycle** (`data-model.md` §17). Obtain an independent audit by a reviewer with no +authoring involvement, who recomputes and matches the hashes and records an **explicit adequacy +finding** — an audit that passes on integrity without reaching adequacy is a FAIL (SC-010). Add +the CI hash-drift check (mechanism 2). + +**Discharges**: FR-053, FR-054, FR-055, FR-057 *(step (a) half)* · SC-010. + +**Depends on**: nothing. It touches `specs/` and CI only, never `packages/**`. + +Note on FR-055: the freeze records the corpus's selection basis and size as *facts about the +frozen corpus*. It does **not** set a production limit. Nothing here may be read as ratifying a +scale bound. + +### Phase C — Consumer package `@adrkit/catalog-envelope` + +**Barrier side: BEFORE.** Barrier-free under **both** readings. The stricter reading concerns +code under `packages/adapters/catalog-backstage/`; this package is not there. Its fixtures are +hand-authored envelopes, so nothing it processes is a descriptor-sourced entity, and its expected +values come from `snapshot-envelope.md` — a contract frozen in `specs/`. + +Implement the five ordered validation steps, digest recomputation, staleness as **exact +inequality** of revision (never an ordering comparison), repository isolation, and +`CatalogSnapshot`-shaped derivation gated behind all five steps passing. Fixtures: a malformed +envelope for each step, a mutated-payload envelope, a stale envelope, a foreign-repository +envelope, and a valid one. + +**Discharges**: FR-004, FR-005, FR-041, FR-044 *(behavioural half)*, FR-045, FR-046, FR-047, +FR-048, FR-049, FR-058 · SC-012 *(the integrity-is-not-correctness framing)*, SC-014. + +**Depends on**: Phase A. + +### Phase D — The adapter's pure validators + +**Barrier side: BEFORE.** Settled by the maintainer's 2026-08-04 adoption of the narrower +reading of clause 6 (see "The clause-6 reach question, resolved" above). Had the stricter +reading been taken, this phase — and only this phase — would sit behind Barrier B. + +*D1, per-descriptor validators*: descriptor reading via `parseDocument` with `uniqueKeys` left at +its default (`duplicate-yaml-key` and `invalid-yaml-syntax` are two **distinct** trigger classes); +the four admissibility validators and the separator rule; two-step canonicalization; annotation +decode in its fixed order, with step 2 checking the annotation node as a YAML **string scalar on +the raw node before** `JSON.parse` — because `JSON.parse` coerces via `ToString`, and the YAML +sequence `["[]"]` would otherwise stringify to `"[]"`, parse as an empty array, and be +misclassified `explicit-empty`; the three ownership states kept distinct, with `explicit-empty` +decided on the **decoded value** (`'[]'`, `'[ ]'`, `'[\n]'` all qualify) and never by raw-string +equality; the glob dialect's fifteen ordered rules, first-match-wins, each accepted pattern +compiled exactly once per derivation run. + +*D2, input-boundary validators*: manifest schema and version/capability rejection; the two-stage +path validation (lexical, then realpath confinement); per-source digest verification; repository +identity as exact string equality on both identity and revision. + +**Discharges**: FR-006 through FR-013, FR-015 through FR-022, FR-025 through FR-033 · SC-004, +SC-005, SC-006, SC-007, SC-008. + +**Depends on**: Phase A. Under the stricter reading, also Phase B. + +Two counting constraints bind this phase. The glob dialect has **fifteen** ordered rules, but +SC-007 requires only rules **1–14** to be exercised: rule 15's `invalid-glob-compile-failure` is +a defensive backstop described in `glob-dialect.md` §3 (lines 33–74, counted in this worktree) as +"expected to never occur in practice, given rules 1–14's exhaustiveness." Its non-occurrence is +conformant and MUST NOT be reported as a coverage gap. Fifteen rules is not fourteen required +exercises, and neither number is the trigger count. + +Research R4 names four validators in its open question; D2's input-boundary validators are +included in the same phase on the same reasoning, so that under the stricter reading the whole +phase moves together and the barrier-free residue is exactly Phases A and C — as R4 states. + +### Phase E — Assembled generator: pipeline, atomicity, envelope emission + +**Barrier side: BEHIND.** Strictly after Phase B clears. This is where the definition of +generator output binds, including the in-memory case (see the divergence note above). + +Compose the Phase D units into the generator; enforce whole-operation atomicity across all +**fifteen** trigger classes with exactly one class recorded per abort and `other-invalid-input` +retained as a deliberate always-present backstop; assemble and emit the envelope with its +`digest` computed via `@adrkit/core`'s `canonicalStringify` (never the same-named function at +`packages/evaluator/src/report/serialize.ts:38`, which is a different function with a different +signature); enforce envelope-only output; record `allRefs` and the provenance boundary between +upstream-authored descriptor content and maintainer-authored overlay. + +**Discharges**: FR-014, FR-023, FR-024, FR-034, FR-035, FR-036, FR-037, FR-038, FR-039, FR-040, +FR-042, FR-043 · SC-001, SC-002, SC-003, SC-009, SC-013. + +**Depends on**: Phases A, B, D. + +Every digest claim emitted from this phase carries the scope qualification from +`contracts/package-boundary.md` §2.2: for the envelope's closed scalar domain the bytes are +*equivalent to* RFC 8785 / JCS output, and no document claims `canonicalStringify` is a +general-purpose RFC 8785 implementation. + +`[NEEDS CLARIFICATION: whether allRefs is populated beyond canonicalId in production. +research.md R12, spec.md line 1275, and data-model.md §5 line 190 all record this as undecided. +The consequence is that it is unknown whether duplicate-canonical-ref is reachable outside +synthetic fixtures — so SC-003's requirement that every trigger class be observed failing may, +for that one class, be satisfiable only synthetically.]` + +### Phase F — Clause-5 step (b): post-output comparison at zero FP / zero FN + +**Barrier side: BEHIND**, and written **after** Phase B's freeze and audit — mechanism 3. + +Author the comparison harness; diff derived ownership for **every** annotated entity in the frozen +accept corpus against the frozen expectations; require zero false positives and zero false +negatives. Any mismatch fails the gate. **The expectations are never amended to fit the output.** +This step records its own hashes and its own PASS/FAIL, inherited from nothing. + +**Discharges**: FR-056, FR-057 *(step (b) half)*, FR-058, FR-063 · SC-011, SC-012. + +**Depends on**: Phases B and E. + +A pass here is a **possible outcome** for ADR-0012 gate 3, never a claim made in advance. Gate 3 +is open today because the oracle carries a known-wrong expected result (R9); Phase B addresses +that directly, and Phase F is where it would be demonstrated — or not. + +### Phase G — Clean clone, offline enforcement, and the clause-8 executable CI gate + +**Barrier side: BEHIND.** It requires a real generator invocation. + +Demonstrate a clean clone building, typechecking, linting, and testing green with both new +packages present; demonstrate a generator run with network access **actively denied**, no +credential, and no service; run the cross-package end-to-end — a generator-written envelope +validated by the consumer; add the ADR-0020 clause 8 executable CI gate tied to clause 5, and +observe it failing before observing it pass. + +**Discharges**: FR-050, FR-052, FR-059 *(repository-wide close-out)*, FR-060, FR-061 · SC-016. + +**Depends on**: Phases C, E, F. + +The clause-8 gate must be a **real CI check**. ADR-0020's own frontmatter assertion is currently +**inert** — `engine: custom` resolves to an optional registry port, no port is registered, and +the result is `status: 'inert'` with `reason: 'assertions-compile.engine-absent'` (R8). Citing +that assertion as enforcement would be wrong. Only a check observed failing counts. + +ADR-0012 gate 4 remains **unmet and not yet testable** after this phase. Its clean-clone, offline, +and adapter-boundary components are producible here; its release component is not. + +`[NEEDS CLARIFICATION: what the "release evidence" component of ADR-0012 gate 4 requires, given +ADR-0020 clause 9 defers release entirely. research.md R9 line 427 and spec.md line 1290 record +the gap at the ADR level; neither resolves it.]` + +--- + +## Where each ADR-0016 observed-failing-first check sits + +Per `research.md` R8, every check lands by three moves: construct the input that should fail, run +it and **observe the failure** recording the exact reason string, then correct the input and +observe the pass. A check only ever observed passing has not been shown to be wired in. + +| Phase | Checks whose failing observation lands here | +| --- | --- | +| A | Adapter isolation: a deliberately introduced non-adapter → adapter dependency observed producing `non-adapter workspace depends on an adapter package` (SC-015). The reverse edge observed producing the allowed-public-surface violation. **And** confirmation that each new package actually has an allowlist entry — because a package with none is silently unconstrained (`scripts/check-deps.ts:151`), so a green `check:deps` there is not evidence of anything. | +| B | Freeze-hash drift: mutate a frozen artifact, observe CI fail, restore, observe pass. Audit FAIL: an oracle whose `derivedPathPatterns` are in input order rather than `compareCodeUnits` order must be observed producing an audit FAIL. An integrity-only audit that never reaches adequacy must likewise be observed as FAIL. | +| C | Each of the five ordered consumer validation steps, individually. Digest mismatch on a mutated payload. Staleness on an exact revision inequality. A foreign-repository envelope refused. Derivation attempted before validation completes, refused. | +| D | Each of glob rules **1–14** (rule 15 is exempt — see Phase D). Each annotation decode step, including the step-2 coercion case where `["[]"]` must **not** be classified `explicit-empty`. Each of the four admissibility validators, separately attributed. A descriptor that is **inadmissible and canonically unique**, observed producing `inadmissible-descriptor` and **not** `duplicate-canonical-id` (FR-021). Manifest version and capability rejections. Both stages of path validation. Repository identity mismatch. | +| E | Each of the **fifteen** fatal trigger classes driven through the full pipeline (SC-003). Whole-operation atomicity: one triggering entity in a batch of otherwise-valid entities produces no envelope, not even a partial one (SC-002). Byte-identical output across three or more runs (SC-001). | +| F | The comparison itself observed failing: introduce a deliberate mismatch between derived ownership and the frozen expectations, observe the gate fail, remove it, observe the gate pass. | +| G | The clause-8 CI gate observed failing before it is observed passing (FR-060). Network denial observed as a *denial*, not as an absence of calls (SC-016). | + +Two placement constraints shape this table. A check whose failing observation requires generator +output over a corpus is **behind** Barrier B by construction. A check producible from a +hand-authored fixture is not — subject to the open question on Phase D. + +One construction constraint applies to Phase D's repository-mismatch fixture: a `git worktree add` +linked worktree **shares remote configuration with its parent**, so the fixture requires a +standalone scratch `git init` repository and can never be a worktree of `mbeacom/adrkit` +(`input-manifest.md` §3.1; R10). This applies to the current worktree, which is exactly such a +linked worktree. + +--- + +## Parallelization verdict + +Stated plainly, because this will be used to decide real concurrent dispatch. Where the +dependency argument is not airtight, the verdict is serial. + +| Pair or phase | Verdict | Reason | +| --- | --- | --- | +| **A ∥ B** | **CONCURRENT** | Disjoint file sets with no read-write overlap. A touches `packages/**` and `scripts/check-deps.ts`; B touches `specs/010-catalog-backstage/evidence/` and adds one CI check. Neither consumes the other's output. B is the barrier, so starting it as early as possible is strictly good. | +| **C ∥ D** | **CONCURRENT** | FR-044 makes the two packages' source trees disjoint *by construction* and forbids an import edge in either direction, so there is no shared file and no build-order coupling. Both depend only on Phase A. This pairing depends on the narrower reading of clause 6, which the maintainer adopted on 2026-08-04; under the stricter reading it would have dissolved. | +| **B ∥ C** | **CONCURRENT** | Holds under **both** readings, and is the fallback if the stricter reading is taken. Phase C is outside `packages/adapters/**` and its expected values come from `snapshot-envelope.md`, so no reading of clause 6 reaches it. Disjoint file sets. | +| **E** | **STRICTLY SERIAL** | Three independent reasons, any one sufficient. It requires A, B, and D complete. It is the first phase that produces generator output under R4's definition, including the in-memory case, so it cannot begin before Barrier B clears. And its atomicity work touches every module Phase D produced, so concurrent edits would collide throughout. | +| **F** | **STRICTLY SERIAL, after E** | R5 mechanism 3 is a *sequencing* requirement, not a dependency: the comparison harness must be authored after the freeze and its audit. Writing it concurrently with B or E collapses ADR-0020 clause 5's two distinct steps into one, and step (b)'s PASS would then inherit from step (a) instead of standing alone. This is the phase where running early does active harm rather than merely risking it. | +| **G** | **SERIAL, after F** | It needs C and E to exist to run the end-to-end at all, and the clause-8 gate it installs is a gate *on clause 5* — which is Phase F. A gate whose subject does not yet exist cannot be observed failing for the right reason. Its clean-clone and network-denial components technically need only E, so a maintainer may split those out; **this plan does not assume that split**, and the verdict stands as serial. | + +### The verdict in sequence form + +The adopted sequence, following the maintainer's 2026-08-04 clause-6 decision: + +```text +[ A ∥ B ] → [ C ∥ D ] → E → F → G +``` + +Recorded as a counterfactual, because it is the thing that would change if that decision were +ever revisited — under the stricter reading of clause 6: + +```text +[ A ∥ B ] → [ B ∥ C ] → D → E → F → G +``` + +— that is, the parallel envelope would shrink to **Phases A and C**, exactly as `research.md` R4 +states. Nothing else in the sequence would change. The plan is deliberately structured so that +the reading costs concurrency and nothing else; no phase's *content* depends on which reading is +taken. + +### Anti-verdicts, stated so they are not inferred + +- **Nothing may run concurrently with Phase E** — not F, not G, not a late slice of D. +- **Phase F may not be started early "so it is ready."** Authoring it early is the specific harm + mechanism 3 exists to prevent. +- **Phase B may not be split** into "freeze now, audit later." SC-010 requires the corpus, its + overlay, its expected matches, and its selection basis and size to be frozen **in the same + cycle**, with the audit recording its own hashes and its own PASS/FAIL. + +--- + +## Carried unknowns + +Two `[NEEDS CLARIFICATION]` markers are carried forward unresolved. Each is a gap at the **ADR +level**, not a drafting omission, and each is recorded at the point where the normative records +stop short. This plan does not resolve either of them, and resolving one is not a prerequisite for +starting. + +1. **`allRefs` population beyond `canonicalId`** — `research.md` R12, `spec.md:1275`, + `data-model.md` §5 line 190. Stated at Phase E. Consequence: reachability of + `duplicate-canonical-ref` outside synthetic fixtures is unknown. +2. **ADR-0012 gate 4's "release evidence" component** — `research.md` R9 line 427, + `spec.md:1290`. Stated at Phase G. Consequence: gate 4 stays not-yet-testable regardless of + how well this feature goes. + +A third — **ADR-0020 clause 6's reach into unit-level validator execution** (`research.md` R4 +line 219) — was **resolved on 2026-08-04 by maintainer decision** in favour of the narrower +reading. See "The clause-6 reach question, resolved" in the Barrier B section. `research.md` R4 +retains the original open marker as the historical record of what was undecided when the +research ran; this plan is where the decision is recorded. Consequence: Phase D is barrier-free +and the adopted sequence is `[A ∥ B] → [C ∥ D] → E → F → G`. + +--- + +## Complexity Tracking + +**No Constitution violations. This table is empty by design, not by omission.** + +Two aspects of the design resemble violations closely enough that a reviewer will reach for this +section; both were evaluated in the Constitution Check and neither is one: + +| Apparent violation | Why it is not one | +| --- | --- | +| The adapter depends on `@adrkit/core` (decision G1) | Principle III describes adapters as packages that "live under `packages/adapters/*`, **depend on the core**, and are permitted to break on upstream churn." G1 is the Principle's expected shape. The independent-versioning concern raised by `research.md` R7 is real but is a documentation obligation, discharged by the `"//versioning"` note precedent in `packages/adapters/spec-kit/package.json` — not a Constitution violation. | +| Both packages declare the envelope's shape independently | Principle V is scoped to the typed frontmatter schema; the envelope is explicitly a separate artifact that never joins it (FR-005). The duplication is load-bearing: a shared type module would be an import edge, which FR-044 forbids, and would make the consumer's structural validation a tautology rather than a check. | + +If either is later judged a genuine violation, the remedy is a change to the design, not an entry +justifying it here. diff --git a/specs/010-catalog-backstage/quickstart.md b/specs/010-catalog-backstage/quickstart.md new file mode 100644 index 00000000..4bdaaa7e --- /dev/null +++ b/specs/010-catalog-backstage/quickstart.md @@ -0,0 +1,301 @@ +# Quickstart: Validating Feature 010 — Backstage Catalog Adapter + +**Feature**: `010-catalog-backstage` +**Companion documents**: [plan.md](./plan.md) (phases and Barrier B), +[spec.md](./spec.md) (FR/SC), [data-model.md](./data-model.md) (entity shapes), +[contracts/](./contracts/) (frozen surfaces). + +This is a **validation and run guide**. It says what to run, in what order, and what must be +true afterwards. It deliberately contains no implementation code: the shapes live in +`data-model.md`, the rules live in `contracts/`, and the sequencing lives in `plan.md`. + +--- + +## 0. Status — read this before running anything + +**Nothing in this guide runs today.** Neither `packages/adapters/catalog-backstage/` nor +`packages/catalog-envelope/` exists. No manifest, no frozen expectation set, no accept-corpus +freeze, and no envelope has been produced. This feature has generated **no evidence** of any +kind. + +Every section below is therefore written in the form *"when phase N is complete, this must +hold"* — never *"this holds."* A reader who converts any of it into a report of a passing state +has produced a false claim. + +Sections are ordered to match `plan.md`'s phase sequence. A section cannot be exercised before +its phase exists, and §4 in particular cannot be exercised before Barrier B has cleared. + +--- + +## 1. Prerequisites + +| Requirement | Value | Where it comes from | +| --- | --- | --- | +| Toolchain | Bun 1.3.14 | root `package.json` `packageManager` | +| Published artifact target | Node `>=22` | root `package.json` `engines.node`; FR-051 | +| Network | Permitted **only** during `bun install --frozen-lockfile` | Constitution Principle II | +| Credentials / services | None, at any point after install | FR-052 | +| Working tree | Clean; the committed `bun.lock` unmodified | Principle II | + +```bash +bun install --frozen-lockfile +``` + +This is the only step in this guide that may reach the network. Every later command must succeed +with the network unavailable, and §7 turns that from a convention into an enforced condition. + +--- + +## 2. Repository-wide gates + +These already exist and must stay green throughout. They are the floor, not the feature. + +```bash +bun run typecheck +bun run build +bun run lint +bun test +bun run check:deps +``` + +**Expected after Phase A**: all five green, with both new packages present and picked up +automatically — the root `workspaces` globs `["packages/*", "packages/adapters/*"]` already match +both new paths, so no workflow or manifest edit is needed to include them. + +**The one green result that means nothing.** `bun run check:deps` passes for a package that has +no `allowedDependenciesFor()` entry, because the function returns `undefined` for unknown +packages (`scripts/check-deps.ts:151`) and the allowed-surface guard is then skipped entirely. A +green `check:deps` is evidence only if you have separately confirmed both new packages have +explicit entries. See `contracts/package-boundary.md` §4. + +--- + +## 3. Phase A — package boundary + +Confirm placement, then confirm the guard actually guards. + +**Placement checks** (no command needed beyond reading the tree): + +- `packages/adapters/catalog-backstage/` exists and is an adapter by location. +- `packages/catalog-envelope/` exists and is **not** under `packages/adapters/**`, which is what + makes it a non-adapter — `isAdapterPackage()` classifies purely by path prefix + (`scripts/check-deps.ts:92–94`). +- Root `package.json` `workspaces` is unchanged. A change there means the placement is wrong. +- The adapter's `package.json` carries a `"//versioning"` note recording that its version is + independent of `@adrkit/core` despite depending on it (ADR-0007; precedent in + `packages/adapters/spec-kit/package.json`). + +**Boundary checks, each observed failing first** (ADR-0016): + +| Construct this | Run | Must produce | +| --- | --- | --- | +| Add `@adrkit/catalog-backstage` to the consumer's `dependencies` | `bun run check:deps` | violation `non-adapter workspace depends on an adapter package` | +| Add `@adrkit/catalog-envelope` to the adapter's `dependencies` | `bun run check:deps` | an allowed-public-surface violation | +| Add any dependency outside each package's allowlist | `bun run check:deps` | an allowed-public-surface violation for that package | + +Revert each, re-run, and observe green. The third row is the only one that closes the §2 trap; +skipping it leaves you unable to distinguish "constrained and satisfied" from "unconstrained." + +--- + +## 4. Phase B — Barrier B + +**This section is the barrier. Do not run §5 or §6 until this section's expected outcomes hold.** + +Barrier B is defined in `plan.md`; its three enforcement mechanisms come from `research.md` R5. +Verifying it is verifying all three, because none is sufficient alone. + +**Mechanism 1 — input absence.** Confirm the generator has no way to discover a corpus: there is +no manifest in the tree, and `input-manifest.md` §5 forbids recursive walking and glob-based +input discovery. This is a design check, not a command. Its point is that the barrier does not +depend on anyone *remembering* not to run something. + +**Mechanism 2 — hash match.** The freeze artifacts live under +`specs/010-catalog-backstage/evidence/` and are tracked in git precisely so CI can re-derive +their hashes. + +```bash +# the hash-drift check added in Phase B, invoked as CI invokes it +bun test # includes the freeze-drift check +``` + +Observed-failing-first: mutate one byte of a frozen artifact, re-run, observe the drift failure, +restore, observe green. A drift check never observed failing has not been shown to be wired in. + +**Mechanism 3 — ordering.** Confirm the comparison harness of §6 **does not yet exist**. Its +absence at this point is the mechanism. If it exists, ADR-0020 clause 5's two distinct steps have +already been collapsed into one and step (b) can no longer stand alone. + +**Expected outcomes for SC-010** — all four required, and the last is the one that gets skipped: + +1. The accept corpus, its maintainer-authored `adrkit.io/owned-paths` overlay, its expected path + matches, and its recorded selection basis and size are frozen **in the same cycle**. +2. An independent reviewer with no authoring involvement recomputes and matches the hashes. +3. That reviewer confirms `derivedPathPatterns` are recorded in `compareCodeUnits`-sorted order. +4. That reviewer records an **explicit adequacy finding**. *An audit that passes on integrity + without reaching adequacy is a FAIL,* not a partial pass. + +The step records its own hashes and its own PASS/FAIL, inherited from nothing. + +Note on the corpus size figure: it is recorded as a fact about the frozen corpus. It is **not** a +production limit, and nothing in this step ratifies a scale bound (FR-055, ADR-0012). + +--- + +## 5. Phases C, D, E — the two packages + +### 5.1 Consumer (Phase C) — runnable before the barrier clears + +Fixtures are hand-authored envelopes; expected values come from `snapshot-envelope.md`. + +```bash +bun test --filter '@adrkit/catalog-envelope' +``` + +Each of the five ordered validation steps must be observed rejecting its own malformed envelope, +individually — a suite that only ever exercises step 1 cannot distinguish a working step 4 from +an absent one. Then: a mutated payload must fail digest recomputation; a stale envelope must fail +on **exact revision inequality** (never an ordering comparison); a foreign-repository envelope +must be refused; and derivation must be refused unless all five steps have passed (SC-014). + +**What a passing envelope means, stated so it is not overstated**: integrity, not correctness +(FR-058, SC-012). A digest match says the bytes were not accidentally corrupted or accidentally +substituted. It says nothing about whether the derived paths are the right paths. That question +is answered only by §6, and only to the strength §6 actually achieves. + +### 5.2 Adapter pure validators (Phase D) + +```bash +bun test --filter '@adrkit/catalog-backstage' +``` + +Coverage required by SC-004 through SC-008, with every check observed failing first: + +- Each of the four admissibility validators, **separately attributed** to the field it was + invoked on (`contracts/admissibility.md` §3). +- At least one descriptor that is **inadmissible and canonically unique** — observed producing + `inadmissible-descriptor` and **not** `duplicate-canonical-id` (FR-021). Without this case a + green suite is equally consistent with an implementation that has fused the two checks. +- Each annotation decode step, including the coercion case where the YAML sequence `["[]"]` must + **not** be classified `explicit-empty`. +- All three ownership states kept distinct, with `explicit-empty` decided on the decoded value. +- Glob rules **1–14**, each with its own rule-specific rejection reason. + +**Do not report rule 15 as a coverage gap.** The dialect has fifteen ordered rules, but rule 15's +`invalid-glob-compile-failure` is a defensive backstop that `glob-dialect.md` §3 describes as +expected never to occur in practice. Its non-occurrence is conformant. SC-007 requires rules +1–14; fifteen rules is not fourteen required exercises, and neither figure is the trigger count. + +**Barrier caveat.** Whether §5.2 may be run before Barrier B clears is the open question carried +in `plan.md`. This guide adopts the narrower reading. If the maintainer takes the stricter +reading, §5.2 moves after §4 and nothing else about this guide changes. + +### 5.3 Assembled generator (Phase E) — strictly after §4 + +All **fifteen** fatal trigger classes must be driven through the full pipeline and each observed +failing (SC-003). Per-rule unit tests from §5.2 do **not** demonstrate whole-operation atomicity; +that is a separate property requiring its own fixtures (`contracts/atomic-fail-closed.md` §2). + +Whole-operation atomicity (SC-002): a batch of otherwise-valid entities plus exactly one +triggering entity must produce a non-zero exit, exactly one recorded trigger class, and **no +envelope at all** — not a partial one, and not one containing the valid entities. + +Determinism (SC-001): three or more runs over identical inputs must produce byte-identical +output. + +Envelope-only output (SC-013): exactly one versioned envelope per successful run, and nothing +else written anywhere. + +--- + +## 6. Phase F — clause-5 post-output comparison + +Run only after §4's outcomes hold and §5.3 has produced output. The harness is authored at this +point and not before (mechanism 3). + +Derived ownership for **every** annotated entity in the frozen accept corpus is diffed against +the frozen expectations. Required: **zero false positives and zero false negatives** (SC-011). +Any mismatch fails the gate. + +**The expectations are never amended to fit the output.** If they diverge, the output is wrong, +or the expectations were wrong when frozen and must be re-frozen through a fresh §4 cycle with a +fresh independent audit — never edited in place. + +This step records its own hashes and its own PASS/FAIL, inherited from nothing. + +A PASS here is a **possible outcome** for ADR-0012 gate 3, which is open today because the +existing oracle carries a known-wrong expected result. It is never a claim made in advance of +running it. + +--- + +## 7. Phase G — clean clone, offline, and the clause-8 gate + +```bash +git clone /tmp/adrkit-clean && cd /tmp/adrkit-clean +bun install --frozen-lockfile +bun run typecheck && bun run build && bun run lint && bun test && bun run check:deps +``` + +Then, with the network **actively denied** rather than merely unused, run the generator over a +manifest and confirm it completes with no credential and no service (SC-016). Denial is the +requirement: "no calls were observed" is a weaker claim and does not satisfy SC-016. The +mechanism is the one described in spike 009's `scale-and-security-measurement.md` §5, cited from +its original location. + +Also run the cross-package end-to-end: an envelope written by the generator, validated by the +consumer, with no import edge between the two packages in either direction. + +Finally, the ADR-0020 clause 8 executable CI gate must be **observed failing** before it is +observed passing. ADR-0020's own frontmatter assertion is currently **inert** — +`status: 'inert'`, `reason: 'assertions-compile.engine-absent'` — so citing it as enforcement is +wrong. Only a real CI check counts. + +**ADR-0012 gate 4 remains unmet and not yet testable after all of this.** Its clean-clone, +offline, and adapter-boundary components are producible here; its release component is not, and +ADR-0020 clause 9 defers release entirely. + +--- + +## 8. Fixture construction notes + +**The repository-mismatch fixture cannot be a worktree.** A `git worktree add` linked worktree +shares remote configuration with its parent, so a fixture built as a worktree of `mbeacom/adrkit` +would inherit the very identity it is supposed to mismatch. Use a standalone scratch +`git init` repository (`input-manifest.md` §3.1). + +This applies to the current checkout, which *is* a linked worktree of `mbeacom/adrkit`. + +**Never write to `/Users/markbeacom/github/mbeacom/adrkit`** — the maintainer's main checkout. + +**The `picomatch` version is read, never transcribed.** Any fixture or expected value that hard- +codes a version string will pass today and silently mislead after any lockfile resolution +change. Read it from the resolved dependency (R3). + +--- + +## 9. What a fully green run does and does not license + +Suppose every section above passes. The warranted sentence is: + +> At the pinned Backstage commit, the maintainer observed the generator produce deterministic, +> atomic, fail-closed behaviour over hand-authored fixtures and one frozen accept corpus, and +> observed derived ownership match independently frozen expectations at zero false positives and +> zero false negatives. + +It does **not** license any of the following, and a document containing one of them fails review: + +- That Backstage-as-a-running-system accepts or rejects anything. The warrant is what a pure + validator **predicate returns when invoked** at the pinned commit. This feature has never run a + Backstage instance and will not. +- That the work is *production-ready*, has an *authoritative go*, or is *release-ready* — unless + the precise state is also named alongside. ADR-0020 authorizes the work, not the release. +- That any external, third-party, or community validation occurred. This is ADR-0014 **rung 1**: + maintainer verification. Only the corpus *data* is third-party; the validation never is. +- That ADR-0012 gate 4 is met. It is not, and after this feature it is still not testable. +- That the digest establishes correctness. It establishes detection of accidental corruption and + accidental substitution, and nothing beyond that (FR-041). + +Three `[NEEDS CLARIFICATION]` markers remain open across `plan.md`, `research.md`, and `spec.md` +regardless of how green this guide runs. They are gaps at the ADR level. A green run does not +close them, and reporting them as closed is a false claim. diff --git a/specs/010-catalog-backstage/research.md b/specs/010-catalog-backstage/research.md new file mode 100644 index 00000000..bdec8bf4 --- /dev/null +++ b/specs/010-catalog-backstage/research.md @@ -0,0 +1,604 @@ +# Phase 0 Research: Production Backstage Catalog Adapter + +**Feature**: `010-catalog-backstage` | **Companion to**: `plan.md`, +`data-model.md`, `contracts/`, `quickstart.md` +**Authorizing record**: +[ADR-0020](../../docs/adr/0020-rescope-sc-010-and-authorize-work-toward-the-backstage-catalog-adapter.md) +(`status: accepted`, 2026-08-04) — authorizes the **work**, not the **release**. + +## How to read this document + +Every entry below records a **Decision**, its **Rationale**, the +**Alternatives considered**, and — where the decision rests on a fact about +this repository rather than on a normative record — the **file and line** at +which that fact was read. Numbers are never asserted from memory; where a +count appears, its source is cited. + +Three standing constraints govern every entry: + +1. **No claim about Backstage-the-running-system.** The only warrant this + feature has is what a **pure validator predicate returns when invoked** at + Backstage commit `1121a4facd9e321179d0402c3f355e4a649e84d9` + ([ADR-0015](../../docs/adr/0015-treat-descriptor-admissibility-as-a-precondition-of-canonicalization.md)). + No statement here asserts that any deployment installs that validator, or + that any catalog backend behaves in any particular way. +2. **No evidence claimed.** This feature has produced nothing. Every + behavioural statement below is a **requirement on future work**, never a + report of an observation. +3. **Unknowns are marked, not invented.** Genuine unknowns carry + `[NEEDS CLARIFICATION: …]` and are carried forward unresolved. + +--- + +## R1. Where the consumer package lives, and whether the workspace globs change + +**Decision.** The envelope validator and `CatalogSnapshot` deriver live in a +new workspace package at **`packages/catalog-envelope/`** (working name +`@adrkit/catalog-envelope`). Root `package.json` `workspaces` is **not** +changed. + +**Rationale.** Root `package.json` (read at `package.json` lines 20–23) +declares: + +```json +"workspaces": [ + "packages/*", + "packages/adapters/*" +] +``` + +`packages/catalog-envelope/` is matched by the existing `packages/*` glob, so +the package is picked up with **zero** change to the workspace configuration. +This matters for two reasons beyond convenience: + +- `scripts/check-deps.ts`'s `isAdapterPackage()` (read at + `scripts/check-deps.ts` lines 92–94) classifies a workspace as an adapter + **solely** by whether its `package.json` path starts with + `packages/adapters/`. A package at `packages/catalog-envelope/` is therefore + **not** an adapter, by construction rather than by exception — so the + `core-has-no-adapter-deps` rule (the violation reason at + `scripts/check-deps.ts` line 179, `'non-adapter workspace depends on an + adapter package'`) is satisfied structurally, not by an allowlisted carve-out. +- Constitution Principle III explicitly permits core and the CLI to depend on + "their own workspace packages", so a future decision to wire the consumer + into a first-party surface is not foreclosed by placement — while remaining + **out of scope here** (see R7). + +**Alternatives considered.** + +- *Put the validator inside `packages/adapters/catalog-backstage/`.* Rejected: + it would make every consumer of a validated envelope depend on an adapter, + which is exactly what `core-has-no-adapter-deps` forbids, and it would + couple the consumer's version to Backstage's semver contract (ADR-0007, + lines 76–79: adapters "are versioned independently, and are permitted to + break on upstream churn. Their semver contract is with their upstream, not + with our core."). +- *Put the validator inside `@adrkit/core`.* Rejected: ADR-0012's + composition rule and spec FR-020 both require that this feature change + **nothing** in `packages/core/src/affects/**`, and adding an envelope + validator to core would grow core's published surface for a capability + whose release is explicitly deferred (ADR-0020 clause 9). +- *A single package containing both generator and consumer.* Rejected by spec + FR-044 and the spec's "Where the consumer lives" note: the adapter and the + consumer must depend on neither each other nor anything but the envelope + file. + +**Explicitly not decided here.** Whether `@adrkit/catalog-envelope` is ever +published, and if so whether in lockstep with `@adrkit/core` or independently, +is **deliberately undecided and out of scope** (spec, Resolved-question +section). Nothing in this plan may be read as having decided it, and no +artifact this feature produces may add a `publishConfig`, a release-manifest +entry, or a version-bump path that presumes an answer. + +--- + +## R2. Which canonicalization function is reused + +**Decision.** Reuse **`canonicalStringify` exported from `@adrkit/core`**, +defined at `packages/core/src/fingerprint/index.ts` line 16 and re-exported +from the package's public surface at `packages/core/src/index.ts` line 24. Its +key ordering comes from `compareCodeUnits`, defined at +`packages/core/src/ordering/index.ts` line 12 and likewise exported from +`@adrkit/core` (`packages/core/src/index.ts`). **No second canonicalization +implementation is written, and no general RFC 8785 library is added.** + +**Rationale.** The requirement (spec FR-041, and +`specs/009-catalog-binding-viability/contracts/snapshot-envelope.md` §3) is a +deterministic canonical byte sequence over the envelope's **closed** scalar +value domain — strings, booleans, `null`, and bounded non-negative integers. +`canonicalStringify` already implements exactly the three steps that contract +names: recursive key sort by code-unit order, arrays serialized in declaration +order, compact separators, `undefined` fields omitted +(`packages/core/src/fingerprint/index.ts` lines 16–27). Both functions are +covered by `packages/core/test/surface.test.ts`'s exported-surface list (lines +35 and 39), so they are part of core's committed public API rather than an +internal detail that could move without notice. + +**Note on a same-named sibling.** A **second, different** `canonicalStringify` +exists at `packages/evaluator/src/report/serialize.ts` line 38, with the +signature `(root: unknown, pretty = false)`. It is **not** the one to reuse: +importing it would put an `@adrkit/evaluator` dependency on a package that has +no other reason to hold one. The core function's signature is +`(value: unknown)` — single argument, no pretty mode. Implementations must +import from `@adrkit/core`, never from `@adrkit/evaluator`. + +**Scope of the claim that must be preserved.** The 009 contract's own +qualification is carried forward verbatim in effect: for the envelope's closed +scalar value domain these bytes are **equivalent to** RFC 8785/JCS output; **no +claim is made** that `canonicalStringify` is a general-purpose RFC 8785 +implementation for arbitrary JSON values. Any artifact this feature produces +that describes the digest MUST carry that qualification. + +**Alternatives considered.** Adding a dedicated JCS/RFC-8785 dependency — +rejected: it enlarges the dependency surface of a package whose release is +deferred, and `scripts/check-deps.ts`'s allowlist model would need to admit +it for no functional gain over the existing, tested function. + +--- + +## R3. How the glob engine and its version are pinned and recorded + +**Decision.** The engine is **`picomatch`**, compiled with options +`{ dot: false, nocase: false, nonegate: true }`. The version written into +`SnapshotEnvelope.globDialect.version` is **read at runtime from the resolved +dependency**, never transcribed from a document. + +**Verified fact.** `bun.lock` declares the range `"picomatch": "^4"` (line 47) +and resolves it to **`picomatch@4.0.5`** (line 165). The companion +`yaml` dependency is declared as `"yaml": "latest"` (line 49) and resolves to +**`yaml@2.9.0`** (line 187). Both were read directly from `bun.lock` in this +worktree; neither is taken on trust from prose. + +**Rationale for reading rather than transcribing.** The engine version is a +**consumer-checked exact value**: `specs/009-catalog-binding-viability/contracts/snapshot-envelope.md` +§2 step 3 requires the consumer to deep-equal +`{ engine: "picomatch", version: "4.0.5", options: { dot: false, nocase: false, nonegate: true } }` +and states plainly that a `globDialect.version`-only check is **insufficient**. +If the generator transcribes a version string that has drifted from the +lockfile's resolution, the envelope silently misdescribes the matcher that +produced it and the consumer's check passes on a false premise. Reading the +resolved version closes that gap mechanically. + +**Consequence to carry into the contracts.** This is a **production delta** +from the spike's contract, which pinned the literal `4.0.5` in a table +(`glob-dialect.md` §1). The value is unchanged today; what changes is its +**provenance** — resolved, not transcribed. Its authority is spec assumption +A6 and the consumer's own exact-value check. Any change to engine, version, or +any option remains a **versioned reclassification** requiring a new +`globDialect.version` in every future envelope, exactly as `glob-dialect.md` +§5 already fixes. + +**Alternatives considered.** Introducing a second matcher, or a different +options combination for this feature's validator — rejected by +`glob-dialect.md` §1's no-substitution rule and by the fact that +`scripts/check-deps.ts`'s `@adrkit/core` allowlist already admits exactly +`picomatch`, `semver`, `zod`, `yaml` (lines 98–105), so a second matcher would +be a new dependency requiring its own justification. + +--- + +## R4. What counts as "generator output" for the barrier + +**Decision.** For the purposes of the Oracle Freeze Barrier (`plan.md`, +"Barrier B"), **generator output** means: + +> a `SnapshotEnvelope` written or returned by the assembled generator, **or** +> any derived-ownership result computed for a descriptor-sourced entity — +> whether persisted, held in memory, or asserted in a test. + +**Rationale.** ADR-0020 clause 6 requires the fresh T014 → T014a cycle +"*before* producing generator output", and clause 5 requires the accept +corpus, its overlay and its expected paths to be "frozen and independently +audited … before any generator output is produced." The control these clauses +implement is **anti-backfilling**: it prevents expectations from being written, +or amended, to fit an output that already exists. The contamination vector is +therefore any artifact that **could be compared against, or used to derive, +the frozen expectations**. A `SnapshotEnvelope` is such an artifact. A derived +`derivedPaths` array for a real descriptor's entity is such an artifact. + +By the same reasoning, a unit-level classification result whose expected value +is fixed by a **contract already frozen in this repository** — for example +that the pattern `packages/{a,..}/**` is rejected at rule 6 with reason +`"brace"` (`glob-dialect.md` §3 worked example) — is **not** such an artifact: +the oracle is not its source of truth, so it cannot backfill the oracle. + +**The distinguishing test, stated so it can be applied by a reviewer:** + +> *Where does this test's expected value come from?* If it comes from a +> contract frozen in `specs/` or `docs/adr/`, the work is barrier-free. If it +> comes from — or could be silently adjusted to match — the oracle expectation +> set or the clause-5 accept-corpus expected paths, it is behind the barrier. + +**Alternatives considered, and the stricter reading that is left open.** + +A stricter reading is available and is **not** foreclosed: that *no code in +`packages/adapters/catalog-backstage/` may execute at all* before the barrier +clears. Under that reading, the pure-validator work in `plan.md` Phase D also +moves behind the barrier, and only Phases A and C remain barrier-free. + +`[NEEDS CLARIFICATION: Does ADR-0020 clause 6's "generator output" bar unit-level execution of the adapter's pure validators (glob dialect, annotation decode, admissibility, identity canonicalization) against hand-authored fixtures whose expected values come from frozen contracts rather than from the oracle? This plan adopts the narrower reading defined above, because the anti-backfilling control it implements has no purchase on a test the oracle does not source. If the maintainer prefers the stricter reading, Phase D moves behind Barrier B and the parallel-dispatch envelope in plan.md shrinks to Phases A and C — no other part of the sequence changes.]` + +**Belt-and-braces control regardless of which reading applies.** Under either +reading, the barrier is enforced **structurally** rather than by discipline — +see R5. + +--- + +## R5. How the barrier is made structural rather than procedural + +**Decision.** Three mechanisms, all required, none sufficient alone: + +1. **Input absence.** The oracle expectation set and the clause-5 accept + corpus overlay + expected paths do **not** exist in the working tree until + their freeze-and-audit steps complete. The generator has no code path that + discovers descriptor files: `specs/009-catalog-binding-viability/contracts/input-manifest.md` + §5 already forbids recursive walking or glob-expansion to "discover" + descriptors, so the **only** way to reach a corpus is a manifest that names + its files explicitly. Absent manifest, absent corpus, absent output. +2. **Hash match.** Each freeze records its own content hashes, and a CI check + re-derives them and fails on drift. This is what makes "the expectations + were never amended to fit the output" a checkable property rather than an + assertion. +3. **Ordering of the comparison harness.** The harness that reads *both* + generator output *and* frozen expectations is written **after** the freeze + and its audit, never before. ADR-0020 clause 5 requires the pre-output + freeze/audit and the post-output comparison to be "two distinct steps, each + recording its own hashes and its own PASS/FAIL"; writing the comparison + harness first would collapse them. + +**Rationale.** ADR-0020 clause 6 is explicit that the repository holds no +existing control here: ADR-0015's Condition of Acceptance 1 "already notes the +scratch bundle is untracked, so nothing in the repository will stop someone +reusing a stale copy. This record adds no control it does not have; it repeats +the clause because repetition is the control." A plan that also relies on +repetition adds nothing. Mechanisms 1–3 are what turn repetition into +structure. + +**Alternatives considered.** Relying on task ordering in `tasks.md` alone — +rejected: task order is advisory to a reader and invisible to CI, and the +carry-forward blocker from spike 009 exists precisely because ordering that +was written down was not mechanically enforced. + +--- + +## R6. How ADR-0015 admissibility composes with canonicalization and the trigger set + +**Decision.** Admissibility is checked **before** canonicalization, and +`inadmissible-descriptor` is a **fatal whole-operation trigger** — bringing the +closed trigger set from the spike's **fourteen** values to **fifteen** for this +feature. + +**Verified counts.** `specs/009-catalog-binding-viability/contracts/atomic-fail-closed.md` +§4 states that every `triggerClass` "MUST be one of exactly these fourteen +values" and lists them (lines 52–67). `inadmissible-descriptor` appears +**nowhere** under `specs/009-catalog-binding-viability/`; its only source is +ADR-0015. Spec FR-035 states the number **fourteen** "is correct for spike 009 +and **wrong for this feature**; it MUST NOT be copied across." The production +count is therefore **fifteen**, and `contracts/atomic-fail-closed.md` in this +feature restates the closed set in full rather than referring a reader to a +document that names a different number. + +**Ordering rationale.** ADR-0015's decision is that admissibility is a +**precondition of** canonicalization, not a check performed alongside it. The +consequence for implementation order is concrete: `metadata.name`/`namespace` +values that fail the pinned four-field validator predicate are rejected +**before** `stringifyEntityRef`-equivalent lowercasing is applied, so an +inadmissible descriptor never acquires a canonical id and therefore can never +participate in a `duplicate-canonical-id` determination. Reversing the order +would let an inadmissible descriptor collide with an admissible one and be +reported under the wrong trigger class. + +**The claim's precise warrant.** The validator table is pinned to Backstage +commit `1121a4facd9e321179d0402c3f355e4a649e84d9`. What is warranted is what +that **predicate returns when invoked**. It is **not** warranted, and must not +be written, that any Backstage deployment installs that policy, that any +catalog backend rejects such a descriptor, or that "Backstage requires" the +four fields. ADR-0015's own review history records that overclaiming phrases +of exactly this kind were found and removed across multiple review rounds; the +qualification is load-bearing, not stylistic. + +**Alternatives considered.** Treating inadmissibility as a per-entity skip — +rejected outright by `atomic-fail-closed.md` §1, whose stated purpose is to +foreclose "skip the bad entity and keep going", and by ADR-0015's Condition of +Acceptance 2, which requires `inadmissible-descriptor` to be carried onto the +atomic surfaces as a **fatal** trigger. + +--- + +## R7. How the dependency-boundary check extends to the two new packages + +**Decision.** `scripts/check-deps.ts`'s `allowedDependenciesFor()` gains an +explicit closed allowlist entry for **each** new package. Neither package is +left to fall through to the function's `return undefined` default. + +**Verified behaviour of the current check.** `allowedDependenciesFor()` (read +at `scripts/check-deps.ts` lines 96–152) returns a per-section allowlist for +exactly five package names — `@adrkit/core`, `@adrkit/cli`, +`@adrkit/evaluator`, `@adrkit/mcp`, `@adrkit/ci` — and **`undefined` for +everything else** (line 151). Where it returns `undefined`, the +`allowed && !allowed[section].has(dependency)` guard (line 200) is skipped +entirely, so a package with no entry is constrained **only** by the +adapter-dependency rule and the GitHub-toolkit rule. A new package left +without an entry is therefore silently unconstrained. Adding entries closes +that gap. + +**Proposed allowlists** (to be implemented, not implemented here): + +| Package | `dependencies` | `devDependencies` | +|---|---|---| +| `@adrkit/catalog-backstage` (at `packages/adapters/catalog-backstage/`) | `picomatch`, `yaml` | `@types/bun`, `@types/picomatch` | +| `@adrkit/catalog-envelope` (at `packages/catalog-envelope/`) | `@adrkit/core` | `@types/bun` | + +The generator's list deliberately **excludes `@adrkit/core`**: ADR-0007's +isolation rule runs in the direction core-must-not-see-adapters, but keeping +the adapter free of a core dependency additionally guarantees that the +adapter's independent versioning (ADR-0007 lines 76–79) is not silently +coupled to core's release cadence. The generator therefore needs its own +canonicalization for the envelope digest — which is a **conflict** with R2's +reuse decision, resolved below. + +**Resolution of the R2/R7 conflict.** The digest is computed by the +**consumer**, not only by the generator: the consumer must "independently +recompute this digest and compare it against the envelope's declared `digest` +value before trusting any entity's `derivedPaths`" +(`snapshot-envelope.md` §3). The consumer is `@adrkit/catalog-envelope`, which +**does** depend on `@adrkit/core` and therefore uses core's +`canonicalStringify` directly, satisfying R2. The generator must also emit a +`digest` field, and it must be byte-identical to the consumer's recomputation. +Two options exist and this plan does not choose between them at Phase 0: + +- **G1**: the generator depends on `@adrkit/core` solely for + `canonicalStringify`/`compareCodeUnits`. Cost: couples the adapter to core. +- **G2**: the generator depends on `@adrkit/catalog-envelope` for the + canonicalization primitives, which in turn holds the core dependency. Cost: + makes the adapter depend on the consumer, contradicting FR-044's "depend on + neither each other" requirement. + +**G1 is the working assumption**, because G2 is directly forbidden by FR-044 +while G1 is only undesirable. G1 is compatible with `check:deps` as written: +`@adrkit/core` is not an adapter, so the adapter-dependency rule does not fire. +The design consequence to record is that under G1 the adapter's allowlist +becomes `@adrkit/core`, `picomatch`, `yaml`, and the adapter's independent +versioning must be documented as **independent of core's semver despite the +dependency**, exactly as `packages/adapters/spec-kit/package.json`'s +`"//versioning"` note already documents for its own case. + +**Out of scope, and named so it is not done by accident.** Wiring +`@adrkit/catalog-envelope` into `@adrkit/cli` would require **amending** +`allowedDependenciesFor('@adrkit/cli')`, whose `dependencies` set is currently +exactly `{'@adrkit/core', '@adrkit/evaluator'}` (`scripts/check-deps.ts` lines +107–115). No spec requirement asks for that wiring, and it would extend a +published surface whose release is deferred (ADR-0020 clause 9). **Do not do +it in this feature.** + +--- + +## R8. What ADR-0016 "observed failing first" requires of each check here + +**Decision.** Every check this feature introduces is landed by the same +three-move sequence, and its failing observation is recorded: + +1. Construct the input that **should** fail. +2. Run the check and **observe it fail**, recording the exact reason string the + check emits. +3. Correct the input (or land the fix) and observe it pass. + +A check that has only ever been observed passing does **not** count as +coverage. This is ADR-0016's rule, and ADR-0020 clause 8 applies it explicitly +to this feature's own release gate: enforcement "counts as coverage only once +it has been watched failing." + +**The inert-assertion trap, stated because it is easy to get wrong.** ADR-0020 +clause 8 records that the frontmatter assertion on ADR-0020 itself is +**currently inert**: `engine: custom` resolves through an optional registry +port, and with no port registered the evaluator returns `status: 'inert'`, +`reason: 'assertions-compile.engine-absent'` — "It records the rule; it does +not enforce it." Any artifact of this feature that cites that assertion as +enforcement is wrong. The enforcement must be a **real CI check**, and it must +be observed failing before it counts. + +**Where the checks sit.** The full placement table is in `plan.md` +("ADR-0016 check placement"). The design constraint that shapes it: a check +whose failing observation would require generator output over a corpus is +itself behind Barrier B; a check whose failing observation can be produced from +a hand-authored fixture is not. + +**Alternatives considered.** Recording "the test suite is green" as coverage — +rejected by ADR-0016's numbered clauses, and by its "report what was examined, +not only what was concluded" requirement. + +--- + +## R9. What this feature can and cannot clear of ADR-0012's production gates + +**Verified gate list.** ADR-0012 names **four** gates on production of +`packages/adapters/catalog-backstage` (read at `docs/adr/0012-*.md` lines +202–220). Their status, as recorded by ADR-0020's own gate table: + +| # | Gate | Status per ADR-0020 | What this feature does to it | +|---|---|---|---| +| 1 | Phase 6 `specs/007-arb-queue/tasks.md` T048-R/T049 clearing | **Satisfied** (2026-07-22) | Nothing. Unchanged in both directions. | +| 2 | Non-shipping spike evidence from `specs/009-catalog-binding-viability/` | **Satisfied** | Nothing. `specs/009-*` is a frozen historical record and is not modified. | +| 3 | A maintainer-authored reference oracle validating real entity/path outcomes | **Unmet** — "The oracle exists but carries a known-wrong expected result and must be re-frozen" | **Directly addressed** by Barrier B steps: the fresh T014 → T014a cycle re-freezes it. Clearing is a *possible outcome* of this feature, not a claim it may make in advance. | +| 4 | Clean-clone / offline / adapter-boundary / **release** evidence passing | **Unmet, and not yet testable** | **Partially addressable**: clean-clone, offline and adapter-boundary components are producible here. The **release** component is not. | + +**The open question this leaves.** + +`[NEEDS CLARIFICATION: What constitutes the "release evidence" component of ADR-0012 gate 4, given that ADR-0020 clause 9 defers both the release vehicle and the decision to release at all? This feature can produce the clean-clone, offline, and adapter-boundary components of that gate. Whether gate 4 is therefore *partially* clearable, or whether it is atomic and remains wholly unmet until a release decision exists, is not resolvable from ADR-0012, ADR-0014, or ADR-0020 as written. Carried forward from spec.md unresolved.]` + +**Binding consequence for every artifact of this feature.** ADR-0020's closing +paragraph authorizes work toward **rung 1 only**, which ADR-0014 calls +"necessary, never sufficient on its own to land a phase whose value is an +operational surface." No artifact may claim `reference-verified`, `landed`, +`released`, `externally validated`, or `adopted`. ADR-0014's state vocabulary +is binding and its synonyms ("production-ready", "authoritative go", +"release-ready") are forbidden unless the precise state is also named. The +maintainer's own isolated reference verification is **rung 2** and MUST NOT be +described as external, third-party, or community adoption — only the corpus +**data** is third-party, never the validation (ADR-0020 clause 5). + +--- + +## R10. Offline and clean-clone posture + +**Decision.** The generator performs **no network access of any kind** at +generation time. Its entire input boundary is: the manifest file; each +descriptor path the manifest's `sources` array lists (digest-verified before +trust); and two git-identity values read via subprocess. This is +`specs/009-catalog-binding-viability/contracts/input-manifest.md` §5, adopted +without change. + +**Verified CI shape.** `.github/workflows/ci.yml` already runs a job named +`clean-clone-builds` (line 12) whose steps include `bun install +--frozen-lockfile`, `bun run typecheck`, `bun run build`, `bun run lint`, +`bun test`, and `bun run check:deps` (lines 23–40). A new workspace package is +picked up by `bun run --filter='*' build` and `bun run --filter='*' lint` +(root `package.json` `scripts.build` / `scripts.lint`) without a workflow +change, and by `bun test` without one either. The **only** CI edit this +feature needs is the clause-8 enforcement check (R8), and that edit is scoped +to adding a check — never to changing the release workflow, which ADR-0020 +clause 9 defers. + +**Bounded scope of the repository-identity check, restated so it is not +overclaimed.** The manifest-vs-checkout comparison confirms the manifest +agrees with the checkout's **own locally-configured git state**. It never +confirms agreement with a live, network-verified remote. A network-verified +provenance check is out of scope. + +**A carried-forward test-construction constraint.** `input-manifest.md` §3.1 +records that a `git worktree add` linked worktree shares its remote +configuration with the repository it was created from, so a repository-mismatch +test cannot be constructed inside one — it requires a **standalone scratch git +repository**. This constraint applies with equal force here, and applies to +this very worktree: any mismatch fixture must be a fresh disposable `git init` +directory with its own `origin`, never a worktree of `mbeacom/adrkit`. + +--- + +## R11. YAML parsing and duplicate-key detection + +**Decision.** Parse descriptor documents with the workspace's existing `yaml` +package, using `parseDocument`, and rely on its `uniqueKeys` option — which +**defaults to `true`** — to surface duplicate mapping keys. Never set +`uniqueKeys: false`, and never post-hoc re-serialize and compare keys. + +**Verified facts.** `bun.lock` resolves `yaml` to **`2.9.0`** (line 187). +`@adrkit/core` already uses this package's `parseDocument` with `{ strict: +true, prettyErrors: false }` at `packages/core/src/parse/frontmatter.ts` lines +1 and 53, so the dependency and the API are both already in use in this +repository rather than newly introduced. `specs/009-catalog-binding-viability/research.md` +lines 385–408 records the `uniqueKeys` default and the requirement that it not +be overridden anywhere in the generator. + +**Why this matters to the trigger set.** `duplicate-yaml-key` and +`invalid-yaml-syntax` are **two distinct** trigger classes +(`atomic-fail-closed.md` §4 and its note at lines 72–78): a document that +fails to parse for a YAML syntax reason *other than* a duplicate key must not +be reported under the duplicate-key trigger. Both are fatal; conflating them +loses a diagnostic the closed type exists to preserve. + +**The step-2 coercion trap that shapes the reader.** The annotation node must +be checked as a **YAML string scalar on the raw node**, before `JSON.parse` is +reached (`owned-paths-annotation.md` §1 step 2). `JSON.parse` coerces a +non-string argument via `ToString`, so the YAML sequence `["[]"]` would be +stringified to `"[]"`, parse cleanly as an empty array, and be **misclassified +as `explicit-empty`**. The parser therefore must expose the raw node's type — +which `parseDocument` does and a plain `parse()` to a POJO does not. This is a +concrete constraint on the parsing API choice, not a stylistic preference. + +--- + +## R12. The `allRefs` population question + +**Status: open, carried forward from `spec.md` unresolved.** + +**What is verified.** `specs/009-catalog-binding-viability/contracts/entity-identity.md` +§2 states that `fixtureAuthoredAliasRefs` "is supplied **directly by a +synthetic fixture's own construction**", that "Backstage itself defines no +standard field for declaring such an alias", and that therefore "**no +real-corpus entity from `community-plugins` or `rhdh-plugins` ever has a +non-empty `fixtureAuthoredAliasRefs`**". It explicitly defers the production +mechanism: "A future production adapter's own mechanism (if any) for sourcing +aliases from real descriptors is an explicitly separate, later, out-of-scope +design decision this contract does not make." + +**Why it cannot be resolved by guess.** The envelope's entity record requires +a **non-empty** `allRefs` string array (`snapshot-envelope.md` §2 step 2). If +`allRefs` is only ever `[canonicalId]` for real descriptors, then the +`duplicate-canonical-ref` trigger — which fires when one entity's alias +collides with a different entity's primary id — is **unreachable outside +synthetic fixtures**. That has a direct consequence for coverage claims: a +`duplicate-canonical-ref` check exercised only on synthetic fixtures must be +reported as such, never as corpus-exercised. + +`[NEEDS CLARIFICATION: How, if at all, is allRefs populated beyond the primary canonicalId in production? Spike 009 sourced alias refs from synthetic fixtures only; no real-corpus entity carries one; Backstage defines no standard alias field; and no ADR decides the question. Consequence: it is unknown whether duplicate-canonical-ref is reachable outside synthetic fixtures. Carried forward from spec.md unresolved.]` + +**Interim design instruction that does not resolve it.** The envelope shape +still requires `allRefs` to be present and non-empty, so the generator emits +`[canonicalId]` as the minimum. That is a **shape** decision forced by the +envelope contract, and it must not be recorded anywhere as an answer to the +question above. + +--- + +## R13. What is deliberately **not** carried forward from spike 009 + +**Decision.** The following spike-009 material is **excluded** from this +feature's design, with authority: + +| Excluded | Authority | +|---|---| +| The B / C / D comparison heuristics (descriptor-parent, repository-root, identity-only) | ADR-0020 clause 7: "No B/C/D comparison heuristic carries into production. Those were measurement instruments, labelled `non-authoritative` by their own contract." Spec FR-061 additionally forbids them "as inferred, authoritative, default, or opt-in ownership behavior". | +| `contracts/comparison-heuristics.md` | Same. | +| The evidence-bundle / three-way-verdict machinery (`go-explicit` / `no-go` / `blocked`, `NonBindingRecommendation`) | Spike apparatus for producing a viability verdict. This feature is authorized work, not a viability question; ADR-0020 already delivered the verdict. | +| `contracts/evidence-bundle-and-verdict.md` | Same. | +| `contracts/scale-and-security-measurement.md` | Measurement apparatus. ADR-0012 lines 222–226 hold that production limits "are **not** guessed now; they must be ratified from evidence" — this feature does not ratify them. | +| The spike's twelve-envelope evidence-bundle inventory (`snapshot-envelope.md` §1's count of 3 required + 9 derivative artifacts) | That inventory is a property of the spike's evidence bundle, not of the envelope contract. The **shape** and the **five validation steps** carry forward; the artifact count does not. | + +**What is carried forward** is enumerated with per-file adoption status in +`contracts/README.md`. + +--- + +## R14. Corpus facts, recorded with their counts and their qualifications + +These are carried from `spec.md`'s Assumptions section. They are recorded here +because several of them are counting traps the repository has failed documents +over, and because the clause-5 accept corpus will be selected against them. + +| Fact | Value | Qualification that must travel with it | +|---|---|---| +| `community-plugins` descriptor files @ `92e9e4e09c76cc57f3475029b73e5ec84498a459` | **156** files / **167** entity documents | File count ≠ entity-document count. Never substitute one for the other. | +| `rhdh-plugins` descriptor files @ `3b355ddfedb23c6656bd9effc8510f9926b765c1` | **38** files / **39** entity documents | 38/39 holds **only** for exact `catalog-info.yaml` basename match; a looser path-suffix match over-counts (spec A5). | +| Descriptors in `community-plugins` carrying **any** `metadata.annotations` | **23** of 156 | Of those, **zero** carry `adrkit.io/owned-paths`. | +| Unsubstituted skeleton descriptors across both corpora | **16** files (5 in `community-plugins`, 11 in `rhdh-plugins`) | Carrying **3** distinct placeholder forms. | +| Placeholder descriptors sharing `${{ values.name \| dump }}` | **14** of those 16 | These **collide** on canonicalization. The two outliers — `bulk-import` (`${{ values.name }}`) and `orchestrator` (`${{ values.entityName }}`), both in `rhdh-plugins` — canonicalize distinctly and **collide with nothing**. | +| Invalid `metadata.name` in `community-plugins` | **7** | **5** on character class, **2** on **length alone**. | +| Invalid `metadata.name` in `rhdh-plugins` | **11** | All on character class. | + +**The trap to avoid in every downstream artifact.** "Over 63 characters" is +**not** the same population as "invalid". A name may be invalid on character +class while being short, and invalid on length while using only permitted +characters. Any statement that conflates the two is wrong. + +**Consequence for the clause-5 accept corpus.** ADR-0020 clause 5 requires the +accept corpus to be "**admissible** under ADR-0015, and free of duplicate +canonical ids". The placeholder-collision and invalid-name populations above +are therefore **selection constraints**, not incidental facts: a corpus +selected without regard to them would fail its own gate on inputs that were +known in advance to fail. The selection basis must record how they were +handled, and that record is part of what the clause-5 audit inspects. + +--- + +## Summary of open items carried into Phase 1 + +| # | Item | Status | +|---|---|---| +| 1 | `allRefs` population beyond `canonicalId` (R12) | **Open.** Carried from `spec.md`. Not resolved by guess. | +| 2 | ADR-0012 gate 4's "release evidence" component (R9) | **Open.** Carried from `spec.md`. Not resolved by guess. | +| 3 | Whether ADR-0020 clause 6's "generator output" bars unit-level execution of pure validators (R4) | **Open, with a stated conservative default** so work is not blocked. Resolving it strictly shrinks the parallel envelope; it changes nothing else. | + +No other unknown is left unmarked. Where this document states a number, the +file and line at which it was read is cited; where it states a requirement, the +normative record imposing it is named. diff --git a/specs/010-catalog-backstage/spec.md b/specs/010-catalog-backstage/spec.md new file mode 100644 index 00000000..46fbb70a --- /dev/null +++ b/specs/010-catalog-backstage/spec.md @@ -0,0 +1,1298 @@ +# Feature Specification: Backstage Catalog Adapter — Offline Owned-Paths Snapshot Generator + +**Feature Branch**: `010-catalog-backstage` + +**Feature Directory**: `specs/010-catalog-backstage` + +**Created**: 2026-08-04 + +**Status**: Draft — **work authorized, release not authorized.** + +**Input**: Author the production feature specification for the Backstage catalog adapter +(`packages/adapters/catalog-backstage/`), under the constraints of +[ADR-0020](../../docs/adr/0020-rescope-sc-010-and-authorize-work-toward-the-backstage-catalog-adapter.md) +action item 1. + +--- + +## Authorization and scope boundary + +This feature is authorized by +[**ADR-0020**](../../docs/adr/0020-rescope-sc-010-and-authorize-work-toward-the-backstage-catalog-adapter.md) +— "Rescope SC-010 and authorize work toward the Backstage catalog adapter" — whose frontmatter +records `status: accepted` and `date: 2026-08-03`, and whose own status banner records it +"ratified by `@mbeacom` on 2026-08-04". Its action item 1 is exactly this document: *"Open the +production feature spec for `packages/adapters/catalog-backstage/` under the constraints above, +citing this record for the SC-010 rescope."* + +**ADR-0020 authorizes the work. It does not authorize the release.** Its Decision section opens +with the bolded statement "Rescope SC-010, and authorize the implementation work required to +clear ADR-0012's remaining production gates. **This record does not itself authorize releasing +the adapter.**" +Clause 9 defers both the release vehicle (publish target, tag, channel) and the decision to +release at all to "a later record made once clause 5 and ADR-0012 gates 3 and 4 are all +demonstrably met." ADR-0012 gate 3 is recorded `Unmet` in ADR-0020's own gate-status table. + +Accordingly, and bindingly on every requirement below: + +- This specification **MUST NOT** claim, imply, or schedule release authorization, a publish + target, a version tag, a distribution channel, or a date for any of them. +- Per ADR-0020's closing paragraph, this work is authorized **toward + [ADR-0014](../../docs/adr/0014-stage-phase-landing-evidence-across-a-three-rung-validation-ladder.md) + rung 1 only**, which ADR-0014 calls "necessary, never sufficient on its own to land a phase + whose value is an operational surface." A catalog adapter is such a surface. Nothing + produced under this specification is reference-verified or externally validated, and neither + this document nor the package may claim otherwise. +- Per ADR-0014's honesty rules, maintainer-owned reference verification is **rung 2** and + **MUST NOT** be described as external, third-party, or community adoption. Only corpus + *data* may be third-party; the validation never is. +- Phase 6's status is unchanged in both directions by this document. + +**This feature has produced no evidence.** At this document's writing no +`packages/adapters/catalog-backstage/` package exists, no generator has been run, no envelope +has been emitted, and no check has been observed failing. Every claim below about behavior is +a *requirement to be met*, never a report of something already demonstrated. + +--- + +## Normative sources + +The ADRs are normative. Where this specification and an ADR disagree, **the ADR wins**. + +| Source | What it binds here | +|---|---| +| [ADR-0020](../../docs/adr/0020-rescope-sc-010-and-authorize-work-toward-the-backstage-catalog-adapter.md) | The authorizing record. Clause 3 (rescoped spike-009 SC-010), clause 5 (accept-path release gate, two distinct steps), clause 6 (fresh T014 → T014a oracle cycle before any generator output), clause 7 (inherited requirements), clause 8 (executable gate observed failing first), clause 9 (release deferred). | +| [ADR-0012](../../docs/adr/0012-bind-catalog-entities-to-owned-paths-with-an-explicit-annotation.md) | The `adrkit.io/owned-paths` contract, the restricted glob dialect, whole-operation atomic fail-closed semantics, the single-repository boundary, the versioned envelope, the four-item production gate list, and "production limits are not guessed now; they must be ratified from evidence." | +| [ADR-0015](../../docs/adr/0015-validate-descriptors-against-backstage-field-formats-before-canonicalizing.md) | Admissibility **before** canonicalization; the four-field validator table pinned to Backstage commit `1121a4facd9e321179d0402c3f355e4a649e84d9`; `inadmissible-descriptor` as a fatal whole-operation trigger. | +| [ADR-0013](../../docs/adr/0013-reconcile-adapter-isolation-and-catalog-binding-with-the-offline-snapshot-genera.md) | Standalone offline generator; **no dynamic runtime adapter/plugin loader**. | +| [ADR-0009](../../docs/adr/0009-affects-resolution-and-catalog-binding.md) | The `CatalogPort` / `CatalogSnapshot` contract this generator ultimately produces for (`packages/core/src/affects/catalog.ts`). | +| [ADR-0007](../../docs/adr/0007-adapter-isolation-and-public-surface-build.md) | Adapter isolation: `packages/core`, `packages/cli` and `schema/` import nothing from `packages/adapters/**`; the adapter is versioned independently, its semver contract being with Backstage rather than with `@adrkit/core`. | +| [ADR-0014](../../docs/adr/0014-stage-phase-landing-evidence-across-a-three-rung-validation-ladder.md) | The three-rung evidence ladder, its state vocabulary, and its binding honesty rules. | +| [ADR-0016](../../docs/adr/0016-require-every-check-to-be-observed-failing-before-it-counts-as-coverage.md) | No check counts as coverage until it has been **observed failing**. | +| [ADR-0010](../../docs/adr/0010-bun-toolchain.md) | Bun toolchain for development; Node-targeted published artifacts. | +| [`.specify/memory/constitution.md`](../../.specify/memory/constitution.md) | Principles I–V. | + +**Design input (mechanism only, not authority).** The contracts under +[`specs/009-catalog-binding-viability/contracts/`](../009-catalog-binding-viability/contracts/) +— `input-manifest.md`, `owned-paths-annotation.md`, `glob-dialect.md`, `entity-identity.md`, +`atomic-fail-closed.md`, `snapshot-envelope.md`, `composition-and-release-boundary.md` — already +designed the mechanism this feature implements, and are cited throughout as the source of +specific enumerations and orderings. They are cited as *design input that itself cites the +ADRs*; they are not independent authority. + +**Spike 009 was a non-shipping spike, and parts of it are deliberately excluded.** Its B/C/D +comparison heuristics were measurement instruments labelled `non-authoritative` by their own +contract, and ADR-0020 clause 7 states plainly: "No B/C/D comparison heuristic carries into +production." Its evidence-bundle and three-way-verdict machinery are likewise not part of this +feature. ADR-0020 clause 1 holds spike 009 unmodified as a historical record; **nothing in this +feature may edit `specs/009-catalog-binding-viability/**`.** + +--- + +## Overview + +This feature builds the minimal viable production slice of a Backstage catalog adapter: a +**standalone offline snapshot generator** that a human or CI invokes directly by name, reads a +vendored or locally-cloned Backstage catalog checkout through **one explicit local input +manifest**, derives each entity's owned repository-relative paths from the +`adrkit.io/owned-paths` annotation **alone**, and writes **only** a versioned +`SnapshotEnvelope`. A separate consumer-side path validates that envelope in full before +anything is derived from it. + +It runs offline: no network, no credentials, and no services at runtime. + +The generator is **fail-closed as its primary behavior, not as an error path**. Under +ADR-0012's whole-operation atomicity rule a single defective descriptor anywhere in the input +aborts the entire operation with a non-zero exit status and no usable partial output. Under +ADR-0020 clause 3 that outcome is a *success* of the criterion, not a failure of it — provided +the rejection is deterministic, atomic, and correctly classified. + +The scope covers eight things, drawn from ADR-0020 clause 7 and spike 009's Output +Recommendation: + +1. read one explicit local input manifest bound to a single repository, with + repository-identity and revision verification against the actual checkout; +2. decode, then validate each descriptor — **admissibility per ADR-0015 before any canonical + identity is computed**; +3. resolve the three-state ownership discriminator (`explicit-paths` / `explicit-empty` / + `annotation-absent`) from `adrkit.io/owned-paths` alone; +4. validate every glob against the restricted dialect; +5. compute canonical lowercase entity identity and fail closed on duplicate canonical ids; +6. enforce whole-operation atomicity over a closed enumeration of fatal trigger classes; +7. write **only** the versioned snapshot envelope — never a `CatalogSnapshot`-shaped artifact + directly; +8. validate an envelope on the consumer side before deriving anything from it, rejecting + malformed, tampered, stale, and misidentified-repository envelopes. + +--- + +## User Scenarios & Testing *(mandatory)* + +User stories are prioritized. Each is independently testable in the sense that it can be +exercised and judged on its own terms with its own fixtures. Two ordering dependencies are +**mandated by ADR-0020 and are not an artifact of this decomposition**: User Story 1 must +complete before any generator output exists at all (clause 6), and User Story 9 can only be +evaluated after generator output exists (clause 5). Those are stated in each story rather than +hidden. + +--- + +### User Story 1 — Freeze and independently audit the ground truth before any generator output exists (Priority: P1) 🎯 MVP prerequisite + +As the maintainer, I want the reference oracle and the clause-5 accept corpus — its +maintainer-authored `adrkit.io/owned-paths` overlay, its expected path matches, and its +recorded selection basis and size — frozen, hashed, and independently audited **before a single +byte of generator output exists**, so that the expectations the generator is later measured +against cannot have been shaped, consciously or otherwise, by what the generator actually +produced. + +**Why this priority**: ADR-0020 clause 6 makes this a hard precondition, not a preference: +"any future feature-009 execution, and any implementation work deriving from one, begins with a +fresh T014 → T014a cycle — correct the `derivedPathPatterns` ordering, re-freeze, re-hash, +obtain a new independent pre-output audit — *before* producing generator output." ADR-0012 +gate 3 is recorded `Unmet` in ADR-0020's gate-status table because spike 009's oracle carries a +known-wrong `derivedPathPatterns` ordering (recorded in input order rather than +`compareCodeUnits`-sorted order). This specification cannot waive that, and does not. + +**Independent Test**: Confirm that the frozen oracle and frozen accept-corpus expectation set +each carry a recorded content hash, that each hash was recorded at a point where no +generator-derived artifact existed, that the independent audit was performed by a reviewer with +no authoring involvement in the artifacts under audit, that the audit recomputed and matched +each hash, that it confirmed `derivedPathPatterns` are recorded in `compareCodeUnits`-sorted +order, and that it recorded an **explicit adequacy finding** about the corpus — not merely an +integrity finding. + +**Acceptance Scenarios**: + +1. **Given** a maintainer-authored reference oracle whose `derivedPathPatterns` are recorded in + `compareCodeUnits`-sorted order, **When** it is frozen and hashed, **Then** the hash is + recorded before any generator-derived artifact exists, and the record states that fact + explicitly. +2. **Given** the frozen oracle, **When** an independent reviewer with no authoring involvement + audits it, **Then** the reviewer independently recomputes the hash, confirms it matches, and + records a PASS or FAIL that is its own — not inherited from the T014 freeze step. +3. **Given** an oracle whose `derivedPathPatterns` are recorded in input order rather than + `compareCodeUnits`-sorted order, **When** the T014a audit runs, **Then** the audit records + FAIL and no generator output is produced until a corrected oracle is re-frozen, re-hashed and + re-audited. +4. **Given** the clause-5 accept corpus, its maintainer-authored `adrkit.io/owned-paths` + overlay, and its expected path matches, **When** they are frozen in the same T014 → T014a + cycle, **Then** the corpus's **selection basis and size are fixed and recorded in that same + cycle**, and the audit records an explicit finding that the corpus is **adequate for the + claim being made** — an audit that passes on integrity without reaching adequacy records + FAIL. +5. **Given** a stale copy of a previously frozen oracle from spike 009's untracked scratch + bundle, **When** any generator run is attempted against it, **Then** the run does not count + toward any gate, because the fresh T014 → T014a cycle is a precondition of generator output + rather than a step that may be satisfied retrospectively. +6. **Given** the frozen expectation set, **When** any later step would amend it to fit observed + generator output, **Then** that amendment is prohibited and the gate fails instead (ADR-0020 + clause 5: "the expectations are never amended to fit the output"). + +--- + +### User Story 2 — Generate a populated envelope, or fail closed correctly, from one manifest-bound real checkout (Priority: P1) 🎯 MVP + +As a maintainer running the adapter against a vendored or locally-cloned Backstage catalog +checkout, I want to invoke the generator by name with exactly one explicit local input +manifest and get **either** a populated, digest-carrying `SnapshotEnvelope` **or** a +deterministic, atomic, correctly-classified fail-closed rejection with no partial output — +never a partial envelope, never a silent skip, and never an entity synthesized to fill a gap. + +**Why this priority**: This is the feature's entire value surface, and it is the shape the +rescoped criterion takes. ADR-0020 clause 3 replaced spike-009 SC-010's demand for a populated +envelope from each named pass with: *"each required pass produces either a populated +`SnapshotEnvelope`, or a deterministic, atomic, correctly-classified fail-closed rejection with +no partial output; and at least one pass over a real corpus meeting clause 5's conditions +produces a populated envelope. A correct rejection of a defective corpus satisfies the +criterion. Fabricating an envelope from one never does."* + +**Independent Test**: Point the generator at a real checkout via one manifest; run it three or +more times; confirm each run terminates in exactly one of the two permitted outcomes; confirm +repeated runs over identical inputs are byte-identical; confirm that on the rejection path the +process exit status is non-zero and no envelope file — complete or partial — is left on disk. + +**Acceptance Scenarios**: + +1. **Given** a valid manifest naming a single repository whose `repository.id` and + `repository.revision` match the actual checkout, and a set of admissible, collision-free + descriptors, **When** the generator runs, **Then** it writes exactly one populated + `SnapshotEnvelope` and exits zero. +2. **Given** the same inputs, **When** the generator is run three or more times, **Then** every + run's output file is byte-identical to every other run's. +3. **Given** a checkout containing at least one descriptor that triggers any fatal class in the + closed enumeration, **When** the generator runs, **Then** it exits non-zero, writes no + envelope, leaves no partial or truncated output file, and reports exactly one correctly + classified trigger class. +4. **Given** a manifest whose `repository.id` or `repository.revision` does not match the values + read from the actual checkout, **When** the generator runs, **Then** it aborts with + `repository-mismatch` **before any entity's paths are derived**, and repository identity is + never read from a descriptor annotation. +5. **Given** a manifest listing a source whose recorded digest does not match the bytes on disk, + **When** the generator runs, **Then** it aborts with `incomplete-required-source` and no + entity is processed. +6. **Given** invocation of the generator, **When** its process runs, **Then** it makes no + network request, reads no credential or bearer-token environment variable, contacts no + service, and requires none of the above to be present. + +--- + +### User Story 3 — Refuse an inadmissible descriptor before any canonical identity exists (Priority: P1) + +As the maintainer, I want each decoded descriptor checked against the pinned Backstage field +formats **before** canonical identity is computed, so that a descriptor whose fields the pinned +validators reject is reported as exactly that — an inadmissible descriptor — and never as a +downstream duplicate-identity condition produced by canonicalizing something that should never +have been canonicalized. + +**Why this priority**: ADR-0015 makes admissibility a **precondition** of canonicalization, not +a step inside it, and ADR-0020 clause 7 inherits that as a requirement. Getting the order wrong +produces the specific misdiagnosis ADR-0015 was written to prevent: several unsubstituted +software-template skeleton descriptors in the pinned corpora share one identical placeholder +string, so canonicalizing first reports a `duplicate-canonical-id` for descriptors that a pure +validator predicate had already excluded. + +**Independent Test**: Feed the generator descriptors that fail each of the four validator +predicates in ADR-0015's table, plus a descriptor set that would produce a duplicate canonical +id *only if* inadmissible descriptors were canonicalized; confirm the reported trigger class is +`inadmissible-descriptor` in every case, that it names the offending source path, the failing +field, and the rejecting validator, and that no canonical identity was computed for the +offending descriptor. + +**Acceptance Scenarios**: + +1. **Given** a descriptor whose `metadata.name` is a string the pinned `isValidObjectName` + predicate returns `false` for, **When** the generator processes it, **Then** the run aborts + with `inadmissible-descriptor`, and no canonical identity for that descriptor is computed, + recorded, or emitted. +2. **Given** two descriptors that both carry the same unsubstituted placeholder as + `metadata.name` — a value the pinned `isValidObjectName` predicate returns `false` for — + **When** the generator processes them, **Then** the reported trigger class is + `inadmissible-descriptor` and **not** `duplicate-canonical-id`, because admissibility is + evaluated first. +3. **Given** a descriptor whose `apiVersion` contains two or more `/` separators, **When** the + generator processes it, **Then** the run aborts with `inadmissible-descriptor`, because the + pinned `isValidPrefixAndOrSuffix` binding returns `false` for such a value. +4. **Given** a descriptor whose `apiVersion` contains no separator at all (for example a bare + `v1`), **When** the generator processes it, **Then** the value is validated against the + suffix predicate alone, the DNS-subdomain rule is not consulted, and the descriptor is + admissible on that field. +5. **Given** a descriptor that omits `metadata.namespace` entirely, **When** the generator + processes it, **Then** the namespace predicate is not applied (it applies only when the field + is present), and the descriptor is admissible on that field. +6. **Given** a descriptor whose present `metadata.namespace` is a string the pinned + `isValidNamespace` binding returns `false` for, **When** the generator processes it, **Then** + the run aborts with `inadmissible-descriptor`. +7. **Given** any inadmissible descriptor anywhere in the input, **When** the generator runs, + **Then** it is never filtered out, skipped, downgraded to a warning, or excluded so that the + remaining descriptors can succeed — the whole operation aborts. +8. **Given** a single descriptor whose `metadata.name` is a value the pinned `isValidObjectName` + predicate returns `false` for but which **canonicalizes uniquely and collides with nothing** + — the case ADR-0015 identifies in `rhdh-plugins` as `bulk-import` (`${{ values.name }}`) and + `orchestrator` (`${{ values.entityName }}`) — **When** the generator processes it, **Then** the + run still aborts with `inadmissible-descriptor`. This is the scenario that proves the class is + load-bearing rather than redundant: no duplicate rule would catch it, because, as ADR-0015 + records, "[d]uplicate detection is not a validity check." + +--- + +### User Story 4 — Derive ownership from `adrkit.io/owned-paths` alone, with the three states never conflated (Priority: P1) + +As a repository owner who has annotated my catalog entities, I want the adapter to derive an +entity's owned paths from the `adrkit.io/owned-paths` annotation and from nothing else, and to +distinguish "I declared these paths," "I declared that I own no paths," and "I declared +nothing" as three separate, never-merged states, so that an absent annotation is never silently +upgraded into an inferred claim of ownership. + +**Why this priority**: ADR-0012 makes the annotation the sole authoritative binding and +explicitly forbids inferring ownership from a descriptor's location or its repository root. +Spike 009 measured descriptor-parent and repository-root as labeled heuristics; ADR-0020 +clause 7 states those measurement instruments do not carry into production at all. + +**Independent Test**: Run fixtures covering all three annotation states and every ordered +decode/validate step; confirm the three states are labeled distinctly in the envelope and no +two are treated as equivalent; confirm each per-pattern rejection reason is distinct and +rule-specific; confirm sorted, deduplicated `derivedPaths` are byte-identical across repeated +runs. + +**Acceptance Scenarios**: + +1. **Given** a descriptor carrying `adrkit.io/owned-paths` with a valid non-empty JSON array of + restricted-dialect globs, **When** the generator derives its paths, **Then** its + `ownershipState` is `explicit-paths` and its `derivedPaths` are the sorted, deduplicated + patterns. +2. **Given** a descriptor whose annotation value is exactly `[]`, **When** the generator derives + its paths, **Then** its `ownershipState` is `explicit-empty` with empty `derivedPaths`, and + it is never conflated with an absent annotation. +3. **Given** a descriptor with no `adrkit.io/owned-paths` annotation at all, **When** the + generator derives its paths, **Then** its `ownershipState` is `annotation-absent`, its + `derivedPaths` are empty, and no path is inferred from the descriptor's own location, its + directory, or the repository root. +4. **Given** an annotation whose YAML value node is not a string scalar — for example a YAML + sequence — **When** the generator decodes it, **Then** it is rejected with the + string-scalar-check reason *before* any JSON parse is attempted, so that a value such as + `["[]"]` cannot be coerced into the string `[]` and misclassified as `explicit-empty`. +5. **Given** an annotation whose string value is not valid JSON, **When** the generator decodes + it, **Then** it is rejected with the parse-error reason, distinct from the string-scalar and + shape reasons. +6. **Given** an annotation that parses to valid JSON but is not an array of strings — an object, + a number, or an array containing a non-string element — **When** the generator validates its + shape, **Then** it is rejected with the wrong-shape reason. +7. **Given** two descriptors with **distinct** canonical ids whose annotations both include an + overlapping pattern, **When** the generator derives paths for both, **Then** the run succeeds, + both entities retain the overlapping pattern in `derivedPaths`, and a file matching it is + owned by both simultaneously — there is no exclusive winner. +8. **Given** patterns that individually violate each rule in the restricted glob dialect's + ordered rule list, **When** the generator validates them in isolation, **Then** each is + classified with its own rule-specific rejection reason, evaluated in the fixed rule order and + stopping at the first match. + +--- + +### User Story 5 — Abort the whole operation atomically, with a correctly classified trigger (Priority: P1) + +As a consumer of the adapter's output, I want a single defective descriptor anywhere in the +input to abort the entire operation with a non-zero status and no usable partial output, so +that I can never be handed a snapshot that silently omits the entities the generator could not +process. + +**Why this priority**: ADR-0012 specifies whole-operation atomic fail-closed semantics rather +than per-entity rejection, and ADR-0020 clause 7 inherits it as a requirement. A per-entity +skip would produce exactly the failure mode the envelope's `completeness` fields exist to +prevent: an artifact that looks complete and is not. + +**Independent Test**: For each fatal trigger class in the closed enumeration, construct an input +in which exactly one entity triggers exactly that class inside an otherwise wholly valid batch; +confirm the run aborts, that the abort is recorded with that exact trigger class, that no +partial output exists — not even for the valid entities in the same run — and that the check was +**observed failing before it was made to pass** (ADR-0016). + +**Acceptance Scenarios**: + +1. **Given** an otherwise-valid batch containing exactly one entity that triggers a fatal class, + **When** the generator runs, **Then** the entire run aborts non-zero and no snapshot is + produced for any entity in that run. +2. **Given** two descriptors that both canonicalize to the identical canonical id, **When** the + generator canonicalizes them, **Then** the run aborts with `duplicate-canonical-id` — never a + first-wins merge, never a last-wins merge, and never two silently coexisting entities. +3. **Given** descriptors whose canonicalized values collide only after case folding, **When** + the generator canonicalizes them, **Then** the collision is detected and the run aborts, + because canonicalization lowercases the entire identity string. +4. **Given** a single source file containing two YAML documents that both canonicalize to the + same canonical id, **When** the generator processes that file, **Then** the run aborts — + descriptor **files** and entity **documents** are counted separately and a multi-document file + is not exempt. +5. **Given** a descriptor file containing a duplicate YAML mapping key, **When** the generator + decodes it, **Then** the run aborts with `duplicate-yaml-key` rather than silently taking + either the first or the last value. +6. **Given** each distinct fatal trigger class in turn, **When** each is exercised through the + full pipeline, **Then** the recorded classification is that exact class and not a neighbouring + one, and each such check was watched failing before it was watched passing. + +--- + +### User Story 6 — Write only the versioned envelope, never a `CatalogSnapshot`-shaped artifact (Priority: P1) + +As the maintainer enforcing ADR-0012's persistence rule, I want the generator's only output to +be the versioned `SnapshotEnvelope`, so that no consumer can ever receive an adapter's raw +output as if it were already validated, and so that the interchange format stays a separate +artifact from the in-memory core types. + +**Why this priority**: ADR-0020 clause 7 states it in one sentence — "The generator writes the +envelope and nothing else" — and ties it to FR-023: "a `CatalogSnapshot`-shaped artifact is +derived from the envelope only after that envelope independently passes every validation and +digest check." + +**Independent Test**: Run the generator to completion on a valid input and enumerate every file +it created or modified; confirm exactly one envelope file and nothing resembling a +`CatalogSnapshot`; confirm the envelope's frozen matcher-contract fields carry the exact +required values; confirm the digest is a SHA-256 over the canonical form of every field except +itself; confirm the digest changes when any covered field changes and does not change when +irrelevant formatting changes. + +**Acceptance Scenarios**: + +1. **Given** a successful generator run, **When** its filesystem effects are enumerated, **Then** + exactly one versioned envelope file was written and no `CatalogSnapshot`- or + `CatalogSnapshotEntity`-shaped artifact was written at all. +2. **Given** a written envelope, **When** its frozen matcher-contract fields are inspected, + **Then** `schemaVersion`, `globDialect` and `capabilities` carry exactly the required values, + and `completeness.wholeCatalog` is `false` because the generator reads only manifest-listed + sources and never walks or globs the tree. +3. **Given** a written envelope, **When** each entity record is inspected, **Then** it carries + exactly the five defined fields — a nested `identity` of `canonicalId` and `allRefs`, + `ownershipState`, `derivedPaths`, a serialized `sourceDocument` of `sourcePath` and + `documentIndexInFile`, and `provenance` — and never a flatter `canonicalId`/`refs`/`paths` + triple. +4. **Given** a written envelope, **When** its digest is independently recomputed over the + canonical form, **Then** the recomputed value matches the recorded `digest`, which is 64 + lowercase hexadecimal characters. +5. **Given** two runs over identical inputs, **When** their envelopes are compared byte for byte, + **Then** they are identical, including the ordering of every array. +6. **Given** an envelope produced from a corpus carrying a maintainer-authored annotation + overlay, **When** its entity records are inspected, **Then** `provenance` distinguishes + upstream-authored descriptor content from the maintainer-authored overlay, so that ADR-0020 + clause 5's "only the corpus data is third-party, never the validation" distinction is legible + from the artifact itself. + +--- + +### User Story 7 — Diff derived ownership against the frozen expectations at zero false positives and zero false negatives (Priority: P1) + +As the maintainer preparing the clause-5 release gate, I want the generator's derived ownership +for **every** annotated entity in the frozen accept corpus diffed against the frozen expected +path matches, with any mismatch failing the gate, so that we never mistake a valid self-digest +for semantic correctness. + +**Why this priority**: ADR-0020 clause 5 is emphatic: *"A populated, digest-verified envelope +proves integrity, not correctness — a semantically wrong envelope can carry a perfectly valid +self-digest."* It states the reason plainly: spike 009's own oracle was, on its evidence +index's admission, "not an executed test harness" — expectations were frozen and then never +diffed against anything. This story exists to close exactly that gap. + +**Ordering**: this story can only be evaluated **after** User Story 1's freeze and audit have +passed and User Story 2 has produced output. That ordering is ADR-0020 clause 5's, which calls +the pre-output freeze/audit and the post-output comparison "**two distinct steps, each +recording its own hashes and its own PASS/FAIL**." + +**Independent Test**: Take the frozen expectation set and the generator's envelope; compute the +diff over every annotated entity; confirm zero false positives and zero false negatives; +confirm the comparison step records its own content hashes and its own PASS/FAIL, separate from +the freeze step's; confirm that a deliberately wrong expectation causes FAIL rather than a +silent amendment. + +**Acceptance Scenarios**: + +1. **Given** the frozen expected path matches and a populated envelope, **When** derived + ownership for every annotated entity is diffed against the expectations, **Then** the gate + passes only on zero false positives **and** zero false negatives. +2. **Given** a single derived path present in the output but absent from the frozen + expectations, **When** the diff runs, **Then** it is recorded as a false positive and the gate + fails. +3. **Given** a single expected path absent from the output, **When** the diff runs, **Then** it + is recorded as a false negative and the gate fails. +4. **Given** a failing diff, **When** any attempt is made to amend the frozen expectations so + that they match the output, **Then** that amendment is rejected and the gate remains failed. +5. **Given** a completed comparison, **When** its record is inspected, **Then** it carries its + own content hashes and its own PASS/FAIL verdict, distinct from and not inherited from User + Story 1's freeze/audit record. +6. **Given** a corpus in which every entity is `annotation-absent`, **When** it is offered as + the clause-5 accept corpus, **Then** it does not satisfy the gate, because it yields a + populated envelope while exercising no derivation at all. +7. **Given** a wholly synthetic entity set, **When** it is offered as the clause-5 accept + corpus, **Then** it does not satisfy the gate, because spike 009 already proved that path. + +--- + +### User Story 8 — Validate an envelope in full before deriving anything from it (Priority: P2) + +As the consumer that turns an envelope into a `CatalogSnapshot`, I want every validation and +digest check to pass **before** I read a single `derivedPaths` value, so that a malformed, +tampered, stale, or wrong-repository envelope is rejected at the correct step rather than +partially trusted. + +**Why this priority**: ADR-0012 requires that any persisted `CatalogSnapshot` require a +validated interchange file first, and ADR-0020 clause 7 inherits FR-023: derivation happens +"only after that envelope independently passes every validation and digest check." It is P2 +rather than P1 only because the generator side must exist before there is anything to consume; +the requirement itself is not optional. + +**Independent Test**: For each malformation kind, each tamper, a stale revision, and a +wrong-repository envelope, confirm rejection at the specific step that owns that condition, and +confirm no `derivedPaths` value was read before rejection. Confirm the contrasting acceptance +case: a valid envelope for a *different* repository is accepted as a valid envelope and simply +returns no results for this repository's query. + +**Acceptance Scenarios**: + +1. **Given** an envelope whose bytes are not valid JSON, **When** the consumer validates it, + **Then** it is rejected at the first validation step and nothing is derived. +2. **Given** an envelope with a missing or wrongly-typed field at any nesting level, **When** the + consumer validates it, **Then** it is rejected at the shape-completeness step. +3. **Given** an envelope whose `schemaVersion`, `globDialect`, or `capabilities` do not carry the + exact required values, **When** the consumer validates it, **Then** it is rejected at the + frozen-matcher-contract step by exact-value comparison, never by a permissive or + range-tolerant comparison. +4. **Given** an envelope in which any `sources[]` entry's digest is missing, wrongly typed, or + does not match the actual bytes, **When** the consumer validates it, **Then** it is rejected + at the source-digest step. +5. **Given** an envelope whose `completeness.identityOnly` is not `false`, **When** the consumer + validates it, **Then** it is rejected at the completeness step. +6. **Given** an envelope whose payload has been mutated after generation so that its recorded + `digest` no longer matches its canonical form, **When** the consumer recomputes the digest, + **Then** the envelope is rejected — and the rejection is reported as detection of accidental + corruption or naive mutation, never as proof of adversarial tamper-resistance. +7. **Given** an envelope whose `repository.revision` is not exactly equal to the consuming + checkout's revision, **When** the consumer evaluates staleness, **Then** the envelope is + rejected on exact inequality, never on a chronological or "newer than" comparison. +8. **Given** an envelope whose `repository.id` does not match the consuming repository, **When** + the consumer evaluates repository identity, **Then** the envelope is rejected as + misidentified. +9. **Given** a wholly valid envelope generated for a *different* repository, **When** this + repository's paths are queried against it, **Then** the envelope is **accepted** as valid and + the query simply returns no matches — repository isolation is a property of the query, not a + rejection. +10. **Given** an envelope that passes every validation, digest, staleness and identity check, + **When** a `CatalogSnapshot`-shaped artifact is derived, **Then** it is derived only at that + point, and an adapter's raw output is never handed to core directly and unvalidated under + any composition arrangement. + +--- + +### User Story 9 — Keep the adapter isolated, and build and run it from a clean clone with no network and no credentials (Priority: P2) + +As a contributor cloning this repository fresh, I want the adapter's presence to change nothing +about how `packages/core`, `packages/cli` and `schema/` build, and I want the whole tree to +build, typecheck, lint, test and run the generator with network access denied and no credentials +present, so that adapter isolation and the offline guarantee are enforced mechanically rather +than by discipline. + +**Why this priority**: ADR-0007 and Constitution Principles II and III make both properties +structural; ADR-0012's gate 4 names "clean-clone / offline / adapter-boundary" evidence +explicitly. It is P2 because it constrains the package rather than producing its value. + +**Independent Test**: Introduce a deliberate violation — a dependency edge from `packages/core` +to `packages/adapters/catalog-backstage` — and confirm the isolation check **fails**; remove it +and confirm the check passes. Separately, from a clean clone, install with the committed +lockfile, then with network access actively denied and no credential environment variables set, +run build, typecheck, lint, test, and one generator invocation, and confirm all succeed. + +**Acceptance Scenarios**: + +1. **Given** a deliberately introduced dependency edge from `packages/core`, `packages/cli`, or + the `schema/` surface to `packages/adapters/catalog-backstage`, **When** the isolation check + runs, **Then** it fails — and this failure is observed and kept as a permanent negative case + before the check is treated as coverage. +2. **Given** the violation removed, **When** the isolation check runs, **Then** it passes, and + `packages/core`, `packages/cli` and `schema/` import nothing from `packages/adapters/**`. +3. **Given** a clean clone, **When** dependencies are installed with the committed lockfile and + the repository is then built, typechecked, linted and tested with network access actively + denied, **Then** every step succeeds without any post-install network access, credential, or + running service. +4. **Given** the generator invoked in that same network-denied, credential-free environment, + **When** it runs against a local checkout, **Then** it completes without attempting any + network access. +5. **Given** the adapter package, **When** its version is inspected relative to `@adrkit/core`, + **Then** it is versioned independently, its semver contract being with Backstage rather than + with `@adrkit/core`. +6. **Given** the adapter package, **When** its composition model is inspected, **Then** it is a + standalone offline generator invoked directly by name, and there is no dynamic runtime + adapter/plugin loader — not even one restricted to a single statically-known package name. + +--- + +### Edge Cases + +- **A descriptor omitting `metadata.namespace`.** Canonicalization substitutes the `default` + namespace before lowercasing. The namespace validator predicate is not applied, because it + applies only when the field is present. +- **A descriptor whose only defect is the length of `metadata.name`.** The pinned + `isValidObjectName` predicate enforces both a character-class pattern and a length bound; + a name that satisfies the pattern and exceeds the bound is still inadmissible. Being "over 63 + characters" is not the same population as "invalid," and the two must not be conflated. +- **An `apiVersion` with no separator.** Validated against the suffix predicate alone; the + DNS-subdomain rule is not consulted. +- **An `apiVersion` with two or more separators.** Rejected by the pinned binding. +- **A single descriptor file containing several entity documents.** Descriptor **file** counts + and entity **document** counts are different quantities. Every check operates per document. +- **An annotation value that is a YAML sequence rather than a string scalar.** Rejected by the + string-scalar check before `JSON.parse` is reached, so that `["[]"]` cannot be coerced to the + string `[]` and misread as `explicit-empty`. +- **An annotation array containing an empty-string element.** Rejected by the empty-pattern rule + in the glob dialect's ordered rules — not silently dropped. +- **Two distinct entities declaring overlapping owned paths.** Permitted. Not a collision. Both + own the overlapping file; there is no exclusive winner. +- **An entity alias colliding with a different entity's primary canonical id.** A + `duplicate-canonical-ref` condition, fatal to the whole operation, not acceptable merely + because the collision involves an alias. +- **A symlink inside the checkout pointing outside it.** The confined-realpath stage fails + closed; a source path that lexically passes but resolves outside the verified checkout root is + rejected. +- **A `Location` entity with `spec.targets`.** The targets are never followed. The generator's + input boundary is the manifest, the manifest-listed digest-verified sources, and the two + repository-identity values read from the checkout. +- **A checkout with more descriptors than the manifest lists.** The generator does not walk or + glob the tree, and the envelope therefore never claims whole-catalog completeness. +- **An entirely `annotation-absent` corpus.** Yields a populated envelope, which is valid output, + but does **not** satisfy ADR-0020 clause 5, because it exercises no ownership derivation. +- **A run that would produce zero entities.** Distinct from a fail-closed abort; must not be + reported as, or confused with, a rejection. + +--- + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Package, composition, and boundary + +- **FR-001**: The feature MUST deliver exactly one new package at + `packages/adapters/catalog-backstage/`. Per **ADR-0007** and **ADR-0020 clause 7**, it MUST be + versioned independently, its semver contract being with Backstage rather than with + `@adrkit/core`. +- **FR-002**: Per **ADR-0013** and **ADR-0020 clause 7**, the adapter MUST be a **standalone + offline snapshot generator** that a human or CI invokes directly by name. There MUST be **no + dynamic runtime adapter/plugin loader of any kind**, and no separate composition host that + discovers, resolves, or dynamically imports a catalog adapter at runtime — not even one + restricted to a single statically-known package name. +- **FR-003**: Per **ADR-0007** and **Constitution Principle III**, `packages/core`, + `packages/cli` and `schema/` MUST import nothing from `packages/adapters/**`, and MUST NOT + otherwise learn that this adapter exists. +- **FR-004**: The feature MUST NOT change `packages/core/src/affects/**`'s existing matcher + semantics, the `CatalogPort` / `CatalogSnapshot` / `CatalogSnapshotEntity` type shapes in + `packages/core/src/affects/catalog.ts`, `packages/core/src/schema/adr.schema.ts`, or the + published `schema/adr.schema.json` (**ADR-0009**, **ADR-0012**, **Constitution Principle V**). +- **FR-005**: The `SnapshotEnvelope` MUST remain a new, separate artifact. It MUST NOT be added + as a field on `CatalogSnapshot` or `CatalogSnapshotEntity`, and MUST NOT become part of any + published schema (**ADR-0012**; + [`snapshot-envelope.md`](../009-catalog-binding-viability/contracts/snapshot-envelope.md) §1). + +#### Input manifest and repository boundary + +- **FR-006**: The generator MUST read exactly **one** explicit local input manifest per + operation, whose schema is **closed**: an unrecognized top-level field is a rejection, not an + ignored extra + ([`input-manifest.md`](../009-catalog-binding-viability/contracts/input-manifest.md) §1; + **ADR-0012**). +- **FR-007**: The manifest MUST bind the operation to a **single repository**. Multi-repository + and federated snapshots are excluded by **ADR-0012**'s single-repository boundary. +- **FR-008**: The generator MUST reject an unsupported manifest schema version, an unsupported + requested snapshot schema version, and an unrecognized requested capability — three distinct + manifest-level version/capability rejections + ([`input-manifest.md`](../009-catalog-binding-viability/contracts/input-manifest.md) §2, which + names them "The Three Manifest-Level Version/Capability Rejections"). Together with + `incomplete-required-source` (FR-011) these are the four manifest-request-level rejections + enumerated by + [`atomic-fail-closed.md`](../009-catalog-binding-viability/contracts/atomic-fail-closed.md) §5. +- **FR-009**: The generator MUST verify the manifest's declared repository identity and revision + against the **actual checkout**, reading those two values through separate git tooling, and + MUST abort on any mismatch in either. Repository identity MUST NOT be read from a descriptor + annotation + ([`input-manifest.md`](../009-catalog-binding-viability/contracts/input-manifest.md) §3; + **ADR-0012**). +- **FR-010**: Repository-identity comparison MUST be exact string equality on both the + normalized identity and the revision. A partial match MUST abort. +- **FR-011**: Every manifest-listed source MUST carry a recorded digest, and the generator MUST + verify each against the bytes on disk before any entity is processed. A missing, wrongly + typed, or mismatched digest MUST abort with `incomplete-required-source` + ([`input-manifest.md`](../009-catalog-binding-viability/contracts/input-manifest.md) §4). +- **FR-012**: Every manifest-listed source path MUST pass **two stages**: a lexical rejection + stage (rejecting empty, `.`, `..`, absolute, leading-separator, drive-prefixed, UNC, + backslash-bearing, traversal-segment-bearing, and NUL-or-control-character paths), and a + **confined realpath** stage requiring the resolved path to lie beneath the verified checkout + root. A symlink that escapes the root MUST fail closed + ([`input-manifest.md`](../009-catalog-binding-viability/contracts/input-manifest.md) §4.1). +- **FR-013**: The generator's input boundary MUST be exactly: the manifest, the manifest-listed + digest-verified sources, and the two repository-identity values read from the checkout. It MUST + NOT follow a `Location` entity's `spec.targets`, MUST NOT invoke any Backstage processor, + plugin, or ingestion pipeline, and MUST NOT walk or glob the tree + ([`input-manifest.md`](../009-catalog-binding-viability/contracts/input-manifest.md) §5). +- **FR-014**: Because FR-013 forbids tree traversal, the envelope MUST always record + `completeness.wholeCatalog` as `false`. The generator MUST NOT claim whole-catalog + completeness under any circumstance. + +#### Admissibility before canonicalization + +- **FR-015**: Per **ADR-0015**, each decoded descriptor MUST be checked for **admissibility + before any canonical identity is computed**. Admissibility is a precondition of + canonicalization, not a step inside it, and no canonical identity may be computed, recorded, or + emitted for an inadmissible descriptor. +- **FR-016**: The admissibility check MUST apply exactly the four field validators in + **ADR-0015**'s table, pinned to Backstage commit + `1121a4facd9e321179d0402c3f355e4a649e84d9`, reproduced here from that table: + + | Field | Validator binding | Predicate | Applied | + |---|---|---|---| + | `apiVersion` | `isValidApiVersion` → `CommonValidatorFunctions.isValidPrefixAndOrSuffix` | prefix is a DNS **subdomain** (`CommonValidatorFunctions.isValidDnsSubdomain`, ≤253 total, each dot-separated label ≤63); suffix is `/^[a-z0-9A-Z]+$/`, ≤63 | required | + | `kind` | `isValidKind` | `/^[a-zA-Z][a-z0-9A-Z]*$/`, ≤63 | required | + | `metadata.name` | `isValidEntityName` → `KubernetesValidatorFunctions.isValidObjectName` | `/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/`, ≤63 | required | + | `metadata.namespace` | `isValidNamespace` → `KubernetesValidatorFunctions.isValidNamespace` → `CommonValidatorFunctions.isValidDnsLabel` | `/^[a-z0-9]+(?:\-+[a-z0-9]+)*$/`, ≤63 | **only when present** | + + The warrant for every admissibility outcome is **what the pinned validator predicate returns + when invoked at that commit**. No requirement here asserts what Backstage as a running system + accepts, rejects, or orders. +- **FR-017**: Per **ADR-0015**, `isValidPrefixAndOrSuffix` splits on `/` and MUST reject any + value containing two or more separators; a value with **no** separator MUST be validated + against the suffix predicate alone, so that a bare `v1` passes without the DNS-subdomain rule + being consulted. +- **FR-018**: An inadmissible descriptor MUST trigger the fatal whole-operation class + `inadmissible-descriptor` (**ADR-0015**; **ADR-0020 clause 7**), with **exactly the same + consequence as every other fatal trigger**: the entire operation aborts with a non-zero exit + status and no usable partial output (FR-034). ADR-0015 states this directly — an inadmissible + descriptor "aborts the entire operation with a non-zero status and no usable partial output, + under a new, distinct trigger class `inadmissible-descriptor`." It MUST NOT be filtered, + skipped, excluded from the input set, treated as a non-entity, downgraded to a warning, or set + aside so the remainder of the batch can succeed; ADR-0015 is explicit that the record "adds a + failure *class*; it removes no failure. Every condition that aborts a run today still aborts a + run." +- **FR-019** *(consequence of FR-015 + FR-018)*: Because admissibility precedes canonicalization + and an inadmissible descriptor is never canonicalized, **no inadmissible descriptor may + participate in a uniqueness comparison** (**ADR-0015**, "Ordering"). It is neither compared for + identity nor silently dropped: it aborts the run. +- **FR-020**: The `inadmissible-descriptor` record MUST identify **all three** of the offending + source path, the failing field, and the validator that rejected it (**ADR-0015**, "Failure + semantics"), and MUST be distinguishable from `duplicate-canonical-id`. Per ADR-0015, + `duplicate-canonical-id` retains its exact meaning — "two or more **admissible** descriptors + canonicalizing to the same identity" — so a descriptor set that would collide only after + canonicalizing an inadmissible descriptor MUST be reported as `inadmissible-descriptor`, "the + earlier and more specific defect." This narrows what `duplicate-canonical-id` reports; it makes + neither condition non-fatal. +- **FR-021** *(why FR-018 is load-bearing, not redundant)*: The implementation MUST NOT treat + duplicate detection as a proxy for validity. ADR-0015 records that of the sixteen unsubstituted + placeholder descriptors in the pinned corpora, the two in `rhdh-plugins` carrying + `${{ values.name }}` (`bulk-import`) and `${{ values.entityName }}` (`orchestrator`) + "canonicalize distinctly" and, "[b]eing canonically distinct, they collide with nothing. They + are exactly as invalid as the other fourteen, and the contract has no mechanism of any kind that + would notice." ADR-0015 concludes: "The duplicate rule catches the fourteen only incidentally, + as a side effect of their sharing a string; behind it there is nothing. Duplicate detection is + not a validity check." Accordingly, conformance evidence MUST include at least one descriptor + that is inadmissible **and** canonically unique, demonstrating that `inadmissible-descriptor` + fires where no duplicate rule would. + +#### Canonical identity + +- **FR-022**: Canonicalization MUST proceed in exactly two steps: if `metadata.namespace` is + omitted, substitute the `default` namespace; then lowercase the **entire** identity string + ([`entity-identity.md`](../009-catalog-binding-viability/contracts/entity-identity.md) §1; + **ADR-0012**). +- **FR-023**: Canonical identity MUST be globally unique across the operation, evaluated over + every ref an entity carries, not only its primary id. Identical canonical ids MUST abort with + `duplicate-canonical-id`; an alias colliding with a different entity's primary id, and a + case-only variant of such a collision, MUST abort with `duplicate-canonical-ref`; a duplicate + YAML mapping key MUST abort with `duplicate-yaml-key` + ([`entity-identity.md`](../009-catalog-binding-viability/contracts/entity-identity.md) §3). + First-wins and last-wins merges are forbidden. +- **FR-024**: Overlapping owned paths between **distinct** canonical ids MUST NOT be treated as a + collision. There is no exclusive winner: every entity whose patterns match a given path MUST be + returned + ([`entity-identity.md`](../009-catalog-binding-viability/contracts/entity-identity.md) §4; + **ADR-0012**). + +#### Ownership derivation + +- **FR-025**: Owned paths MUST be derived from the `adrkit.io/owned-paths` annotation **alone** + (**ADR-0012**). The generator MUST NOT infer ownership from a descriptor's own location, its + parent directory, the repository root, or any other signal. +- **FR-026**: The annotation MUST be decoded and validated in a fixed order, each step producing + its own distinct rejection reason + ([`owned-paths-annotation.md`](../009-catalog-binding-viability/contracts/owned-paths-annotation.md) + §1): (1) presence check; (2) **string-scalar check** on the raw YAML node, performed **before** + any JSON parse; (3) JSON parse; (4) shape check requiring exactly an array of strings; (5) + per-pattern glob validation. +- **FR-027**: Step 2 of FR-026 is load-bearing and MUST NOT be reordered or omitted: because + `JSON.parse` coerces its argument via `ToString`, a non-string YAML node such as a sequence + containing `[]` would otherwise be silently coerced and misclassified as `explicit-empty`. +- **FR-028**: The generator MUST resolve exactly three ownership states — `explicit-paths`, + `explicit-empty`, and `annotation-absent` — and MUST record the resolved state in the envelope. + No two of the three may be conflated, and an absent annotation MUST NOT be treated as + equivalent to an explicitly empty one + ([`owned-paths-annotation.md`](../009-catalog-binding-viability/contracts/owned-paths-annotation.md) + §3; **ADR-0012**). + +#### Restricted glob dialect + +- **FR-029**: Every derived pattern MUST be validated against the restricted glob dialect, whose + engine and options are **frozen**: `picomatch`, at the exact version resolved in the + repository's committed `bun.lock`, with options `{ dot: false, nocase: false, nonegate: true }` + ([`glob-dialect.md`](../009-catalog-binding-viability/contracts/glob-dialect.md) §1; + **ADR-0012**). At this document's writing `bun.lock` resolves `picomatch@4.0.5`; the frozen + value is the lockfile's resolution, which the implementation MUST record in the envelope's + `globDialect` and MUST verify rather than assume. +- **FR-030**: Pattern validation MUST apply the **fifteen** ordered rules of + [`glob-dialect.md`](../009-catalog-binding-viability/contracts/glob-dialect.md) §3 in that + exact order, stopping at the first match, producing the fifteen distinct rejection reasons that + section defines, or `accepted`. Counted from that section's numbered list. The final rule is + the engine compile, whose compile failure is a defensive backstop rather than the primary + gate. +- **FR-031**: A pattern's rejection reason MUST be rule-specific. A batch containing several + distinct violations MUST classify each individually when each is validated in isolation, while + a mixed batch in one operation still aborts the whole operation per FR-034. +- **FR-032**: Each accepted pattern MUST be compiled once per run and reused, so that validation + and matching cannot diverge + ([`glob-dialect.md`](../009-catalog-binding-viability/contracts/glob-dialect.md) §6). +- **FR-033**: `derivedPaths` MUST be sorted with `compareCodeUnits` and deduplicated, so that the + envelope's array ordering is a function of content alone. + +#### Whole-operation atomic fail-closed + +- **FR-034**: Any fatal trigger MUST abort the **entire operation** with a non-zero process exit + status and **no usable partial output** — not even for the otherwise-valid entities in the same + run (**ADR-0012**; + [`atomic-fail-closed.md`](../009-catalog-binding-viability/contracts/atomic-fail-closed.md)). + Per-entity rejection is forbidden. +- **FR-035**: The enumeration of fatal trigger classes MUST be **closed**. It comprises the + **fourteen** values enumerated by + [`atomic-fail-closed.md`](../009-catalog-binding-viability/contracts/atomic-fail-closed.md) §4 + — `duplicate-canonical-id`, `duplicate-canonical-ref`, `duplicate-yaml-key`, + `invalid-yaml-syntax`, `invalid-manifest-shape`, `invalid-annotation-shape`, + `invalid-annotation-parse`, `invalid-pattern`, `unsupported-manifest-version`, + `unsupported-snapshot-version`, `unsupported-capability`, `repository-mismatch`, + `incomplete-required-source`, `other-invalid-input` (counted from that section's own list, + which requires the value be "one of exactly these fourteen values") — **plus** + `inadmissible-descriptor`, which **ADR-0015** adds as a distinct fatal class. **That is + fifteen classes for this feature.** + + The authority for the fifteenth is **ADR-0015's Condition of Acceptance 2**, whose preamble + states that its three conditions "were attached at ratification and are binding on any work + that cites this record." Condition 2 requires that "the follow-up must carry + `inadmissible-descriptor` onto the atomic surfaces," that "[a]dding the class to the identity + contract alone is insufficient," and that the atomic-fail-closed and data-model surfaces "must + both name it as a fatal whole-operation trigger, or the fail-closed guarantee ADR-0012 pins is + described in one place and not another." **This feature is that follow-up**, so the obligation + binds here directly rather than by inheritance alone; **ADR-0020 clause 7** independently + carries the same requirement forward. + + `inadmissible-descriptor` does not appear anywhere under + `specs/009-catalog-binding-viability/`. ADR-0015 is its only source, and the implementation + MUST take it from there. The number **fourteen** is correct for spike 009 and **wrong for this + feature**; it MUST NOT be copied across. +- **FR-036**: `other-invalid-input` MUST remain a deliberate always-present backstop, never a + substitute for a more specific class that applies. +- **FR-037**: Each abort MUST record exactly one trigger class, and that class MUST be the + correct one — not a neighbouring class that happens to also be reachable. + +#### Envelope output + +- **FR-038**: The generator MUST write **only** the versioned `SnapshotEnvelope`. It MUST NOT + write a `CatalogSnapshot`-shaped artifact directly, under any circumstance (**ADR-0020 + clause 7**: "The generator writes the envelope and nothing else"). +- **FR-039**: The envelope MUST carry the fields defined by + [`snapshot-envelope.md`](../009-catalog-binding-viability/contracts/snapshot-envelope.md) §1 — + `schemaVersion`, `repository`, `generatorVersion`, `globDialect`, `capabilities`, + `completeness`, `sources`, `entities`, `digest` — and each `entities[]` record MUST carry + exactly the five defined fields: a nested `identity` of `{ canonicalId, allRefs }`, + `ownershipState`, `derivedPaths`, a serialized `sourceDocument` of + `{ sourcePath, documentIndexInFile }`, and `provenance`. A flatter `canonicalId` / `refs` / + `paths` triple MUST NOT be emitted. +- **FR-040**: The envelope's `digest` MUST be a SHA-256 over the **canonical form** of every + field except `digest` itself — recursive key sort by `compareCodeUnits`, arrays preserved in + declaration order, compact separators, `undefined` omitted — rendered as 64 lowercase + hexadecimal characters + ([`snapshot-envelope.md`](../009-catalog-binding-viability/contracts/snapshot-envelope.md) §3). +- **FR-041**: Every claim made about the digest MUST be scoped to **detection of accidental + corruption and naive mutation only**. Neither the implementation, its documentation, nor any + evidence produced under this feature may claim adversarial tamper-resistance + ([`snapshot-envelope.md`](../009-catalog-binding-viability/contracts/snapshot-envelope.md) §3). +- **FR-042**: Identical inputs MUST produce **byte-identical** output across repeated runs, + including array ordering and serialization details (**ADR-0012**; **Constitution + Principle IV**). +- **FR-043**: `provenance` MUST distinguish upstream-authored descriptor content from + maintainer-authored annotation overlay, so that **ADR-0020 clause 5**'s "only the corpus data + is third-party, never the validation" boundary is legible from the artifact. + +#### Consumer-side validation before derivation + +**Where the consumer lives.** The envelope validator and `CatalogSnapshot` +deriver live in their **own workspace package**, separate from both the adapter +and `@adrkit/core` — working name `@adrkit/catalog-envelope`. Three constraints +converge on that placement and none of them is satisfied by any other home: + +- It is **not** the adapter. **ADR-0020** clause 7 states "the generator writes + the envelope and nothing else." +- It is **not** `@adrkit/core` or `@adrkit/cli`. **ADR-0007** and Constitution + Principle III keep them from learning an adapter exists, and + [`composition-and-release-boundary.md`](../009-catalog-binding-viability/contracts/composition-and-release-boundary.md) + §2 says they "receive only an already-validated `CatalogSnapshot`-shaped + artifact" — an artifact they consume, not a format they parse. +- It is **not** `schema/`. **ADR-0012** and FR-005 keep the envelope out of the + published schema, which is `schema/adr.schema.json`, the canonical ADR JSON + Schema hosted at its `$id` per **ADR-0011**. That constraint is about the ADR + schema specifically and is unaffected by a separate package validating + envelopes. + +Constitution Principle III permits this directly: `@adrkit/core` and +`@adrkit/cli` "MUST depend only on the filesystem, **their own workspace +packages**, and a small set of vetted, deterministic, network-free, +credential-free public libraries." A sibling workspace package is an explicitly +permitted dependency, and because it sits outside `packages/adapters/**` the +`core-has-no-adapter-deps` check is satisfied by construction rather than by +exception. + +- **FR-044**: The envelope validator and `CatalogSnapshot` deriver MUST live in a + workspace package that is **not** under `packages/adapters/**`, MUST NOT import + from any adapter, and MUST NOT add the envelope to `schema/adr.schema.json`. + The adapter MUST NOT depend on it, and it MUST NOT depend on the adapter — the + envelope file is the only interface between them, exactly as + `composition-and-release-boundary.md` §2 requires. Its dependency direction and + its absence from `packages/adapters/**` MUST both be enforced by the + dependency-graph check, each observed failing first (**ADR-0016**). + +- **FR-045**: Before deriving anything from an envelope, the consumer MUST apply the **five** + validation steps of + [`snapshot-envelope.md`](../009-catalog-binding-viability/contracts/snapshot-envelope.md) §2 in + order, each mapping one-to-one to a malformation kind (counted from that section's numbered + list): (1) valid JSON; (2) complete shape at every nesting level; (3) frozen matcher contract + by **exact value** — `schemaVersion`, `globDialect`, `capabilities`; (4) every `sources[]` + digest present, correctly typed, and matching the actual bytes; (5) + `completeness.identityOnly === false`. +- **FR-046**: Only after all five validation steps pass may the consumer proceed to digest + verification, then staleness evaluation, then repository-identity evaluation. No + `derivedPaths` value may be read before all of those pass. +- **FR-047**: Staleness MUST be evaluated as **exact inequality** of revision, never as a + chronological or "newer than" comparison + ([`snapshot-envelope.md`](../009-catalog-binding-viability/contracts/snapshot-envelope.md) §4). +- **FR-048**: An envelope whose `repository.id` does not match the consuming repository MUST be + rejected as misidentified + ([`snapshot-envelope.md`](../009-catalog-binding-viability/contracts/snapshot-envelope.md) §5). + Distinctly, a **valid** envelope for a different repository MUST be **accepted** as valid, with + repository isolation expressed as the query returning no matches — isolation is a property of + the query, not a rejection + ([`snapshot-envelope.md`](../009-catalog-binding-viability/contracts/snapshot-envelope.md) §6). +- **FR-049**: A `CatalogSnapshot`-shaped artifact MUST be derived from the envelope **only after** + that envelope independently passes every validation and digest check (**ADR-0020 clause 7**, + inheriting FR-023 of spike 009). An adapter's raw output MUST NOT be handed to core directly + and unvalidated under any composition arrangement. + +#### Environment and toolchain + +- **FR-050**: A clean clone MUST build, typecheck, lint, and test green. Network access is + permitted **only** during dependency installation with the committed lockfile; after install + there MUST be no network access, no credential, and no running service required by build, test, + or generator invocation (**Constitution Principle II**; **ADR-0007**). +- **FR-051**: Development MUST use the Bun toolchain, and any published artifact MUST target + Node and be smoke-tested under Node, not only under Bun (**ADR-0010**). This requirement + constrains the artifact's shape; it does not authorize publishing it (see Out of Scope). +- **FR-052**: The generator MUST require no network, no credential, and no service at runtime, + and MUST NOT degrade to a networked path when one is available. + +#### Evidence, gates, and honesty + +- **FR-053**: Per **ADR-0020 clause 6**, work MUST begin with a fresh T014 → T014a cycle: + correct the `derivedPathPatterns` ordering to `compareCodeUnits`-sorted order, re-freeze, + re-hash, and obtain a new independent pre-output audit — **before producing generator output**. + This specification does not waive it and cannot. +- **FR-054**: Per **ADR-0020 clause 5**, the accept corpus, its maintainer-authored + `adrkit.io/owned-paths` overlay, its expected path matches, and its **selection basis and size** + MUST be frozen and independently audited within that same T014 → T014a cycle, before any + generator output. The audit MUST record an **explicit finding that the corpus is adequate for + the claim being made**; an audit that passes on integrity without reaching adequacy does not + satisfy the clause. +- **FR-055**: Per **ADR-0012** — "production limits are **not** guessed now; they must be + ratified from evidence" — and **ADR-0020 clause 5**, **no minimum entity count may be invented + by this specification or by the implementation**. Adequacy is the independent auditor's + recorded judgement against the frozen corpus. +- **FR-056**: Per **ADR-0020 clause 5**, after generator output exists, derived ownership for + **every** annotated entity MUST be diffed against the frozen expectations and MUST match with + **zero false positives and zero false negatives**. Any mismatch fails the gate. The + expectations MUST NOT be amended to fit the output. +- **FR-057**: The pre-output freeze/audit (FR-053, FR-054) and the post-output comparison + (FR-056) MUST be recorded as **two distinct steps, each recording its own hashes and its own + PASS/FAIL** (**ADR-0020 clause 5**). Neither may inherit the other's verdict. +- **FR-058**: A populated, digest-verified envelope MUST be reported as evidence of **integrity, + not correctness**. No document or artifact produced under this feature may present a valid + self-digest as evidence that the derived ownership is semantically right (**ADR-0020 + clause 5**). +- **FR-059**: Per **ADR-0016**, no check counts as coverage until it has been **observed + failing**, and the failing input MUST be retained as a permanent negative case. This applies to + the adapter-isolation check (FR-003), every fatal trigger class (FR-035), every consumer + validation step (FR-045), and the clause-5 CI gate (FR-060). +- **FR-060**: Per **ADR-0020 clause 8**, clause 5 MUST get an **executable CI gate tied to + release**, observed failing first. ADR-0020's own frontmatter assertion is **currently inert**: + its `engine: custom` resolves through an optional registry port, and with no port registered + the evaluator returns `status: 'inert'`, `reason: 'assertions-compile.engine-absent'`. It + records the rule; it does not enforce it. This specification MUST NOT treat that assertion as + enforcement. +- **FR-061**: Per **ADR-0020 clause 7**, **no B/C/D comparison heuristic** from spike 009 carries + into production. Descriptor-parent, repository-root, and identity-only normalization MUST NOT + appear in the adapter as inferred, authoritative, default, or opt-in ownership behavior. +- **FR-062**: Per **ADR-0020**'s closing paragraph and **ADR-0014**, this work is authorized + toward **rung 1 only**. Neither the package, its documentation, its tests, nor any evidence it + produces may claim reference-verified (rung 2) or externally validated (rung 3) status. Where + maintainer-owned verification occurs, it MUST NOT be described as external, third-party, or + community adoption; only corpus **data** may be called third-party. +- **FR-063**: Per **ADR-0012**'s "heuristics are not defaults" rule and **ADR-0020 clause 5**, + the adapter's documentation MUST state that adoption of `adrkit.io/owned-paths` by anyone other + than the maintainer is neither established nor gated by this feature. + +### Key Entities + +- **Input Manifest**: The single, closed-schema, local file that binds one operation to one + repository, declares the requested snapshot schema version and capabilities, and lists every + source with its digest. The generator reads nothing outside it except the two repository + identity values read from the checkout. +- **Descriptor Document**: One entity document decoded from one source file. A source **file** + may contain several **documents**; these are different quantities and every check operates per + document. +- **Admissibility Result**: The outcome of applying ADR-0015's four pinned validator predicates + to a decoded descriptor, computed **before** any canonical identity exists. +- **Canonical Entity Identity**: The fully lowercased `kind:namespace/name` string, with the + `default` namespace substituted when `metadata.namespace` is absent. Globally unique across the + operation, evaluated over every ref. +- **Ownership State**: One of exactly three values — `explicit-paths`, `explicit-empty`, + `annotation-absent` — resolved from `adrkit.io/owned-paths` alone and never conflated. +- **Derived Paths**: The sorted, deduplicated restricted-dialect glob patterns an entity declares + it owns. Non-empty only in the `explicit-paths` state. +- **Fatal Trigger Class**: One member of the closed enumeration in FR-035. Exactly one is + recorded per aborted operation. +- **Snapshot Envelope**: The generator's only output. A versioned interchange artifact carrying + the frozen matcher contract, source digests, entity records, and a self-digest. Separate from + `CatalogSnapshot` and never part of any published schema. +- **Frozen Expectation Set**: The maintainer-authored accept-corpus overlay, its expected path + matches, and its recorded selection basis and size — frozen, hashed and independently audited + before generator output exists, and never amended afterwards. + +### Out of Scope + +The following are explicitly excluded and MUST NOT be introduced by this feature: + +- **Release authorization of any kind**, and any publish target, npm package name, version tag, + distribution channel, publish trigger, release date, or change to `.github/workflows/**` that + would effect one. ADR-0020 clause 9 defers both the release vehicle and the decision to release + at all to a later record. +- Any claim, anywhere in the package or its documentation, of **ADR-0014 rung 2 or rung 3** — + reference-verified, externally validated, adopted, or sustained adoption. +- Any claim that this feature clears ADR-0012 gate 3, or that the clause-5 gate is met, in + advance of the two distinct recorded steps (FR-053 through FR-057) actually passing. +- **Spike 009's B/C/D comparison heuristics** (descriptor-parent, repository-root, + identity-only), in any form — including as an opt-in, labeled, or diagnostic mode. +- Spike 009's **evidence-bundle and three-way-verdict machinery** (`go-explicit` / + `no-go` / `blocked`), its `NonBindingRecommendation`, and its scale-and-security measurement + apparatus. +- Any edit to `specs/009-catalog-binding-viability/**`, which ADR-0020 clause 1 holds unmodified + as a historical record, or to any ADR. +- A dynamic runtime adapter or plugin loader of any kind (**ADR-0013**). +- Any call to a live Backstage API, catalog backend, discovery processor, or ingestion pipeline; + any bearer token, API key, or credential; any catalog mutation, synchronization, or write-back. +- Following a `Location` entity's `spec.targets` to read a file outside the input manifest. +- Multi-repository or federated snapshots. +- Cryptographic signing, attestation, or any adversarial tamper-resistance mechanism for the + envelope. The digest's guarantee is scoped to accidental corruption and naive mutation only, + and strengthening it is a separate, later decision this feature does not make. +- Network-verified repository provenance. Repository identity is verified against the local + checkout only. +- Any change to `packages/core/src/affects/**` matcher semantics, to the + `CatalogPort`/`CatalogSnapshot` type shapes, or to the ADR schema. +- Guessing or ratifying a production scale limit, entity-count floor, or performance budget from + this feature's evidence alone. +- Any assertion about what Backstage as a running system accepts, rejects, orders, or tolerates. + Warrants are limited to what a pinned validator predicate returns when invoked. + +--- + +## Success Criteria *(mandatory)* + +> **Numbering caution.** The `SC-0NN` identifiers below are **local to this document**. Spike +> 009 has its own separately-numbered success criteria, and ADR-0020 clause 3 rescopes *spike +> 009's* SC-010. Every reference to that rescope is written out in full as **spike 009's +> SC-010** to prevent collision with this document's own SC-010. + +### Measurable Outcomes + +- **SC-001** *(determinism)*: Running the generator three or more times over identical inputs + produces byte-identical output on every run, including every array's ordering — on the accept + path and on the reject path alike. + +- **SC-002** *(whole-operation atomicity)*: Introducing exactly one entity that triggers a fatal + class into an otherwise wholly valid batch causes the entire operation to abort with a non-zero + exit status and no usable partial output — not even for the valid entities in the same run — in + every tested case. + +- **SC-003** *(closed trigger enumeration, each observed failing)*: Every fatal trigger class in + FR-035's closed enumeration is exercised through the full pipeline, each recorded with its own + exact classification rather than a neighbouring one, and each check is **observed failing before + it is observed passing**, with the failing input retained as a permanent negative case + (**ADR-0016**). + +- **SC-004** *(admissibility precedes canonicalization)*: For every descriptor that any of + ADR-0015's four pinned validator predicates returns `false` for, the recorded trigger class is + `inadmissible-descriptor`, no canonical identity for that descriptor is computed or emitted, + and a descriptor set that would collide only after canonicalizing an inadmissible descriptor is + never reported as `duplicate-canonical-id`. The exercised set includes **at least one + inadmissible descriptor that canonicalizes uniquely and collides with nothing** (FR-021), so + the criterion cannot be satisfied by a duplicate rule firing incidentally; and each + `inadmissible-descriptor` record carries all three of the offending path, the failing field, + and the rejecting validator (FR-020). + +- **SC-005** *(three states never conflated)*: Across a fixture set containing all three + ownership states, each entity's recorded `ownershipState` is exactly one of `explicit-paths`, + `explicit-empty`, `annotation-absent`; no two are treated as equivalent; and no path is ever + derived for an `annotation-absent` entity. + +- **SC-006** *(annotation decode order)*: Each of the annotation's ordered decode/validate steps + produces its own distinct rejection reason when violated in isolation, and a non-string YAML + node is rejected by the string-scalar check **before** any JSON parse is attempted. + +- **SC-007** *(glob dialect)*: Each of rules 1–14 in + [`glob-dialect.md`](../009-catalog-binding-viability/contracts/glob-dialect.md) §3's fifteen + ordered rules is exercised by at least one pattern that violates that rule and no earlier one, + each yielding its own rule-specific rejection reason; rule 15's `accepted` outcome is exercised + by valid patterns. Rule 15's `invalid-glob-compile-failure` reason is **not** required to be + exercised: that contract calls it a defensive backstop "expected to never occur in practice, + given rules 1–14's exhaustiveness," so a run that never produces it is conformant and MUST NOT + be reported as a coverage gap. The envelope's recorded `globDialect` matches the engine and + options actually used, verified rather than assumed. + +- **SC-008** *(repository boundary)*: A repository-identity or revision mismatch between the + manifest and the actual checkout aborts before any entity's paths are derived, in every tested + case; repository identity is never read from a descriptor annotation; and a source path that + lexically passes but resolves outside the verified checkout root fails closed. + +- **SC-009** *(the rescoped criterion — spike 009's SC-010, per **ADR-0020 clause 3**)*: Each + required pass produces **either** a populated `SnapshotEnvelope` **or** a deterministic, atomic, + correctly-classified fail-closed rejection with no partial output; **and** at least one pass + over a real corpus meeting ADR-0020 clause 5's conditions produces a **populated** envelope. A + correct rejection of a defective corpus satisfies this criterion; fabricating an envelope from + one never does. This is the criterion **as rescoped by ADR-0020 clause 3** — spike 009's + original SC-010, which required a populated envelope from each of its three named passes, was + unsatisfiable under FR-001's frozen inputs, and ADR-0020 clause 2 locates that defect in the + criterion rather than in the generator, in ADR-0012's fail-closed contract, in ADR-0015's + admissibility rule, or in the pinned field-format model. + +- **SC-010** *(clause 5 step (a) — pre-output freeze and independent audit)*: Before any + generator output exists, the accept corpus, its maintainer-authored `adrkit.io/owned-paths` + overlay, its expected path matches, and its recorded selection basis and size are frozen and + hashed; an independent reviewer with no authoring involvement recomputes and matches those + hashes, confirms `derivedPathPatterns` are recorded in `compareCodeUnits`-sorted order, and + records an **explicit adequacy finding**. This step records **its own** hashes and **its own** + PASS/FAIL. An audit that passes on integrity without reaching adequacy is a FAIL. + +- **SC-011** *(clause 5 step (b) — post-output comparison at zero FP / zero FN)*: After generator + output exists, derived ownership for **every** annotated entity in the frozen accept corpus is + diffed against the frozen expectations and matches with **zero false positives and zero false + negatives**. Any mismatch fails the gate. The expectations are never amended to fit the output. + This step records **its own** hashes and **its own** PASS/FAIL, inherited from nothing. + +- **SC-012** *(integrity is not correctness)*: No artifact, report, or document produced under + this feature presents a populated, digest-verified envelope as evidence of semantic + correctness. Every such claim is scoped to integrity, and correctness is claimed only on the + strength of SC-011. + +- **SC-013** *(envelope-only output)*: On every successful run, exactly one versioned envelope + file is written and no `CatalogSnapshot`- or `CatalogSnapshotEntity`-shaped artifact is written + by the generator; each entity record carries exactly the five defined fields; and the recorded + digest matches an independent recomputation over the canonical form. + +- **SC-014** *(consumer rejection and isolation)*: Each of the five ordered consumer validation + steps rejects at its own step for its own malformation kind; a mutated envelope is rejected on + digest recomputation; an envelope whose revision is not exactly equal to the consuming + checkout's is rejected on exact inequality; an envelope whose repository id does not match is + rejected as misidentified; and — as the contrasting acceptance case — a **valid** envelope for a + different repository is accepted, with the query simply returning no matches. In every rejection + case, no `derivedPaths` value was read before rejection. + +- **SC-015** *(adapter isolation, observed failing first)*: A deliberately introduced dependency + edge from `packages/core`, `packages/cli`, or the `schema/` surface to + `packages/adapters/catalog-backstage` causes the isolation check to **fail**; that failure is + observed and retained as a permanent negative case; and with the edge removed the check passes. + +- **SC-016** *(clean clone, offline, credential-free)*: From a clean clone, after dependency + installation with the committed lockfile, the repository builds, typechecks, lints and tests + green and one generator invocation completes successfully with network access **actively + denied** — not merely "no network calls happened to occur" — and with no credential or + bearer-token environment variable set. + +- **SC-017** *(rung honesty)*: No file in `packages/adapters/catalog-backstage/`, and no document + produced by this feature, claims reference-verified, externally validated, adopted, or sustained + adoption status; and no maintainer-owned verification performed under this feature is described + as external, third-party, or community adoption. + +--- + +## Assumptions and Risks + +- **A1 — This feature has produced no evidence.** At this document's writing there is no + `packages/adapters/catalog-backstage/` package, no generator run, no envelope, and no observed + failing check. Every behavioral statement above is a requirement, not a report. + +- **A2 — ADR-0012 gate 3 is open, and this feature does not waive it.** ADR-0020's own gate-status + table records gate 3 as `Unmet`: spike 009's reference oracle carries a known-wrong + `derivedPathPatterns` ordering, recorded in input order rather than `compareCodeUnits`-sorted + order. ADR-0020 clause 6 requires a fresh T014 → T014a cycle before any generator output. That + cycle is carried forward here as FR-053 and User Story 1, not resolved by this document. ADR-0015's + Condition of Acceptance 1 notes the spike's scratch bundle is untracked, so nothing in the + repository mechanically prevents reuse of a stale oracle copy; the control is the repeated + clause, and this document repeats it. + +- **A3 — Adoption of `adrkit.io/owned-paths` is not established and is not gated here.** ADR-0020 + records that "Of 156 `community-plugins` descriptor files, 23 carry any `metadata.annotations` + and **zero** carry `adrkit.io/owned-paths`," and that "no third-party descriptor in the pinned + corpora uses it." ADR-0020 draws the consequence itself: "against these two corpora a future + adapter would derive no owned paths at all, because neither corpus carries the annotation; + whether that generalizes to catalogs beyond the pin is not established here, and this record + does not assert it." The adapter's real-world value is therefore contingent on adopter uptake, + which this feature does not and cannot establish. ADR-0014 forbids treating external adoption + as a blocker, and ADR-0012 gate 3 — as quoted by ADR-0020 — calls it "welcome as an optional + later production-maturity signal … **not** a hard gate." This is recorded as a standing risk, + not a solved problem. + +- **A4 — Neither pinned corpus qualifies as the clause-5 accept corpus as it stands.** ADR-0020 + clause 5 requires the accept corpus to be admissible under ADR-0015 and free of duplicate + canonical ids. Spike 009's evidence index records that `community-plugins` at its pinned commit + contains seven descriptors whose `metadata.name` the pinned `isValidObjectName` predicate + returns `false` for (five on character class, two on length alone), and `rhdh-plugins` contains + eleven (all on character class); ADR-0015 records sixteen unsubstituted software-template + skeleton descriptors across the two corpora — five in `community-plugins` and eleven in + `rhdh-plugins` — carrying **three** distinct placeholder forms, of which fourteen share one + identical string and therefore collide. ADR-0020's Context additionally records a fully + admissible duplicate pair in `community-plugins`. The accept corpus's identity, construction and + selection basis are therefore **deliberately not fixed by this document**: ADR-0020 clause 5 + assigns them to the T014 → T014a cycle, "fixed and recorded in that same cycle, not chosen + afterwards." ADR-0020's own Consequences section contemplates that no qualifying corpus may be + identifiable or constructible, and names that as a revisit trigger. + +- **A5 — Descriptor file counts and entity document counts are different quantities.** Spike 009's + evidence index records 156 files / 167 entity documents for `community-plugins` and 38 files / + 39 entity documents for `rhdh-plugins`, and notes that the 38/39 figure holds only for an exact + `catalog-info.yaml` basename match — a looser path-suffix match over-counts. Any count this + feature records MUST state which quantity it is counting and how it was matched. + +- **A6 — The frozen glob engine version is the lockfile's resolution, not a declared range.** + `packages/core/package.json` declares `picomatch` as a range; the frozen value is what + `bun.lock` resolves, which at this document's writing is `picomatch@4.0.5`. The implementation + MUST read and record the actual resolved version rather than trust this document's + transcription of it. + +- **A7 — The digest's guarantee is narrow.** It detects accidental corruption and naive mutation. + It is not an adversarial tamper-resistance mechanism, and strengthening it is explicitly out of + scope for this feature. + +- **A8 — Release remains undecided in both dimensions.** Neither the release vehicle nor the + decision to release at all is made here; ADR-0020 clause 9 assigns both to a later record made + once clause 5 and ADR-0012 gates 3 and 4 are all demonstrably met. + +### Resolved questions + +- **Which package owns the consumer-side envelope validation and `CatalogSnapshot` + derivation?** **Resolved 2026-08-04 by maintainer decision: its own workspace package, + separate from both the adapter and `@adrkit/core`** (working name + `@adrkit/catalog-envelope`). Recorded as FR-044 and in the "Where the consumer lives" + note above it. ADR-0020 clause 7 places it outside the generator, ADR-0007 and + Constitution Principle III place it outside core and the CLI, and ADR-0012's + published-schema constraint is about `schema/adr.schema.json` specifically (ADR-0011), + which a separate validating package does not touch. Constitution Principle III + permits it directly — core and the CLI may depend on "their own workspace packages" — + and because the package sits outside `packages/adapters/**`, `core-has-no-adapter-deps` + is satisfied by construction rather than by exception. + + Two consequences are deliberately **not** decided here and are out of scope for this + specification: whether the package is published or remains private to the workspace, + and if published, whether it versions in lockstep with the core surface or + independently (ADR-0007 distinguishes the two). Both belong with the release decision + ADR-0020 clause 9 defers. + +### Open questions + +- **[NEEDS CLARIFICATION: How, if at all, is `allRefs` populated beyond the primary + `canonicalId` in production?]** `duplicate-canonical-ref` is a required fatal trigger class + (FR-023, FR-035) and `allRefs` is a required envelope field (FR-039), so alias refs are + structurally present. But spike 009's + [`entity-identity.md`](../009-catalog-binding-viability/contracts/entity-identity.md) §2 sourced + aliases from synthetic fixtures only, recording that "Backstage itself defines no standard field + for declaring such an alias, so **no real-corpus entity from `community-plugins` or + `rhdh-plugins` ever has a non-empty `fixtureAuthoredAliasRefs`**," and that "a future production + adapter's own mechanism (if any) for sourcing aliases from real descriptors is an explicitly + separate, later, out-of-scope design decision this contract does not make." No ADR makes it + either. Until it is + decided, this specification cannot say whether a real-corpus run may ever produce an `allRefs` + array longer than one element, nor therefore whether `duplicate-canonical-ref` is reachable + outside synthetic fixtures. + +- **[NEEDS CLARIFICATION: What constitutes the "release evidence" component of ADR-0012 gate 4, + given that ADR-0020 clause 9 defers the release vehicle?]** ADR-0012's fourth production gate is + "clean-clone / offline / adapter-boundary / **release** evidence passing," and ADR-0020's + gate-status table records it as "Unmet, and not yet testable" because no package exists. This + feature can produce the clean-clone, offline, and adapter-boundary components (FR-050, FR-003, + SC-015, SC-016). It cannot determine what "release evidence" means in the absence of a decided + publish target, tag, and channel — which clause 9 explicitly defers. Whether gate 4 is + therefore partially clearable by this feature, or must wait entirely on the later release + record, is not resolvable from the ADRs. diff --git a/specs/010-catalog-backstage/tasks.md b/specs/010-catalog-backstage/tasks.md new file mode 100644 index 00000000..d0d50c3c --- /dev/null +++ b/specs/010-catalog-backstage/tasks.md @@ -0,0 +1,1537 @@ +# Tasks: Production Backstage Catalog Adapter + +**Feature**: `010-catalog-backstage` +**Feature directory**: `specs/010-catalog-backstage/` +**Input documents** (all frozen; read, obeyed, never edited by any task below): +`spec.md`, `plan.md`, `research.md`, `data-model.md`, +`contracts/README.md`, `contracts/atomic-fail-closed.md`, `contracts/admissibility.md`, +`contracts/package-boundary.md`, `checklists/requirements.md`, `quickstart.md` + +**Adopted contracts inherited unchanged or with delta from spike 009** +(`specs/009-catalog-binding-viability/contracts/`, per `contracts/README.md` §2): +`glob-dialect.md`, `owned-paths-annotation.md`, `snapshot-envelope.md`, +`input-manifest.md`, `entity-identity.md`. + +**Normative governance**: ADR-0007 (versioning), ADR-0010 (`bun test` only), +ADR-0012 (gates), ADR-0013, ADR-0014 (evidence rungs), ADR-0015 (conditions of +acceptance), ADR-0016 (observed-failing-first), ADR-0020 (authorization of this +work), Constitution v1.0.2. + +--- + +## Standing constraints — these bind every task in this document + +1. **This feature has produced nothing.** No package exists. No generator has run. + No envelope exists. No check has been observed failing. Every statement in this + document is a *requirement*, never a *report*. No task may cite evidence that + does not yet exist. + +2. **ADR-0014 rung 1 only.** ADR-0020 authorizes the *work*, not the *release*. + No task may schedule, imply, or prepare a release. No task may claim rung 2 or + rung 3. Only corpus *data* may be described as third-party; never the validation. + +3. **Honesty about warrant.** No task, test name, comment, or document may assert + what *Backstage as a running system* does. The warrant available to this feature + is exactly: what a pure validator predicate returns when invoked against + descriptor content pinned at commit `1121a4facd9e321179d0402c3f355e4a649e84d9`. + +4. **Toolchain.** Tests are `bun test` (ADR-0010). Never jest, vitest, npm, yarn, + or pnpm. Typecheck is `bun run typecheck`. Dependency boundary is + `bun run check:deps`. + +5. **ADR-0016 is binding.** A check that has only ever been observed passing is not + coverage. Every check-building task carries an explicit paired step that + introduces a violation, observes the check fail *and records the exact reason + string emitted*, then restores and observes the pass. These are never collapsed + into "write the check" — the observation *is* the coverage (`research.md` R8). + +6. **ADR-0020's own frontmatter assertion is inert.** + (`engine: custom` → optional registry port → none registered → `status: 'inert'`, + `reason: 'assertions-compile.engine-absent'`.) No task may cite it as enforcement. + The clause-8 gate must be a real CI check, observed failing before observed passing. + +7. **Generating this task list marks no task complete and performs no + implementation.** Every checkbox below is unchecked and stays unchecked until + the corresponding work is actually done and observed. + +--- + +## Barrier B — the organizing constraint + +ADR-0020 clause 6 and clause 5(a), plus ADR-0012 gate 3, require that the fresh +T014 → T014a oracle cycle **and** the clause-5 accept-corpus freeze/audit — overlay, +expected paths, recorded selection basis and size, and an explicit adequacy finding — +all complete **before any generator-derived output exists**. + +### What counts as generator output (`research.md` R4) + +A `SnapshotEnvelope` written or returned by the assembled generator, **or** any +derived-ownership result computed for a descriptor-sourced entity — whether +persisted, held in memory, or asserted in a test. + +### The distinguishing test (`research.md` R4) + +> *Where does this test's expected value come from?* + +- Expected value comes from a contract frozen in `specs/` or `docs/adr/` → **barrier-free**. +- Expected value is sourced from — or could be silently adjusted to match — the + oracle expectation set or the clause-5 expected paths → **behind the barrier**. + +Where the definition and the distinguishing test diverge (whole-operation atomicity +over a mixed batch), `plan.md` takes **the definition**: all assembled-generator work +sits behind the barrier, in Phase E. + +### Three enforcement mechanisms (`research.md` R5) — all required, none sufficient alone + +| # | Mechanism | How this task list enforces it | +|---|---|---| +| 1 | **Input absence** — no manifest in the tree ⇒ no corpus ⇒ no output. Backed by `input-manifest.md` §5's ban on recursive walking and glob discovery. | T024 confirms no manifest exists anywhere in the tree before Phase E may begin. | +| 2 | **Hash match** — CI re-derives the freeze hashes and fails on drift. This is why the freeze artifacts must be git-tracked under `specs/010-catalog-backstage/evidence/`. | T022 builds the drift check; T023 observes it failing; T091 re-verifies the hashes are unchanged across all of E and F. | +| 3 | **Ordering** — the comparison harness is authored strictly *after* freeze and audit. | T024 confirms no comparison harness exists anywhere; T087 authors it and records that it did not exist before T024. | + +### Phase → barrier side (preserved exactly from `plan.md`) + +| Phase | Scope | Barrier side | Depends on | +|---|---|---|---| +| **A** | Workspace placement + dependency-boundary enforcement | **BEFORE** | — | +| **B** | Barrier B itself: fresh T014→T014a cycle + accept-corpus freeze/audit | **IS THE BARRIER** | — | +| **C** | Consumer package `@adrkit/catalog-envelope` | **BEFORE** | A | +| **D** | Adapter pure validators | **BEFORE** *(settled 2026-08-04 by maintainer decision in favour of the narrower clause-6 reading)* | A | +| **E** | Assembled generator: pipeline, atomicity, envelope | **BEHIND** | A, B, D | +| **F** | Clause-5 step (b): post-output comparison | **BEHIND** | B, E | +| **G** | Clean clone, offline, clause-8 CI gate | **BEHIND** | C, E, F | + +**T024 is the hard gate.** Every task in phases E, F, and G lists `T024` in its +`Depends` line. The dependency graph — not prose — is what makes it mechanically +impossible to start behind-barrier work early. + +--- + +## Task format + +``` +- [ ] T001 [P] [US1] Description naming the exact file(s) created or modified + Barrier: BEFORE | IS THE BARRIER | BEHIND + Discharges: FR-xxx, SC-xxx (or: none — supports FR-xxx) + Depends: T000, T000 (or: none) + Contract: §
(omitted when no contract applies) +``` + +- **`[P]`** appears **only** where tasks touch disjoint files, share no state, and + have no ordering dependency on one another. Where there is any doubt, the task is + serial. 20 of 100 tasks carry `[P]`. +- **Story labels.** Per `.specify/templates/tasks-template.md`, setup and + foundational scaffolding carries no story label. Applied here: Phase A's + pure-scaffolding tasks are unlabeled; Phase A tasks that directly serve a US9 + acceptance scenario carry `[US9]`; all tasks in phases B–G carry their story label. +- **Discharges** names the FRs and SCs the task closes. Where `plan.md` assigns one + identifier to two phases, the task states *which named half* it discharges, and the + coverage table lists both task IDs. See "Coverage" below — this is stated plainly + rather than forced into a fabricated 1:1. + +### Path conventions + +| Alias | Path | +|---|---| +| `` | `packages/adapters/catalog-backstage/` | +| `` | `packages/catalog-envelope/` | +| `` | `specs/010-catalog-backstage/evidence/` | + +Root-level checks follow the existing repository convention: implementation in +`scripts/.ts`, test alongside at `scripts/.test.ts` (six such pairs +already exist, e.g. `scripts/check-deps.test.ts`). + +--- + +## Phase A — Workspace placement and dependency-boundary enforcement + +**Barrier side: BEFORE.** No task in this phase reads a descriptor, computes an +ownership result, or produces an envelope. Phase A may run concurrently with Phase B. + +- [ ] T001 [P] Create the adapter package skeleton at `` — `package.json` + (name `@adrkit/catalog-backstage`, `type: module`, `publishConfig.access: public`, + and the `"//versioning"` note citing ADR-0007 and `ReleaseVersioning` in + `scripts/release-pack.ts`, following the `packages/adapters/spec-kit/` precedent), + `tsconfig.json`, `src/index.ts`, `LICENSE`, `NOTICE`. + Barrier: BEFORE + Discharges: FR-001 + Depends: none + Contract: `package-boundary.md` §2, §6 + +- [ ] T002 [P] Create the consumer package skeleton at `` — `package.json` + (name `@adrkit/catalog-envelope` — working name, `type: module`), + `tsconfig.json`, `src/index.ts`. This package is **not** under + `packages/adapters/` and must not be. + Barrier: BEFORE + Discharges: FR-044 (placement half) + Depends: none + Contract: `package-boundary.md` §3 + +- [ ] T003 Confirm both packages are picked up by the existing root `workspaces` + globs `["packages/*", "packages/adapters/*"]` (root `package.json` lines 18–21) + **without modifying them** — a needed change to those globs is a signal that + placement is wrong. Declare the Node target in each package's `engines` field; + the existing `node-smoke-built-artifacts` CI job then covers it. + Files: `/package.json`, `/package.json` (verify only: + root `package.json`). + Barrier: BEFORE + Discharges: FR-051 + Depends: T001, T002 + +- [ ] T004 [P] [US9] Write `/README.md` using ADR-0014 rung-1 language only, + with no rung-2 or rung-3 synonyms, and carrying the FR-063 adoption statement: + what a downstream consumer may and may not conclude from this adapter's output. + Barrier: BEFORE + Discharges: FR-062, FR-063 (documentation half), SC-017 + Depends: T001 + +- [ ] T005 [P] Write `/README.md` under the same rung-1 honesty constraint, + framing the package as an integrity validator and never as a correctness oracle. + Barrier: BEFORE + Discharges: none — supports SC-017 + Depends: T002 + +- [ ] T006 [P] [US9] Add a structural assertion test proving the adapter is reachable + only by explicit static import and registers no dynamic loader, plugin registry, + or discovery hook. + Files: `/test/no-dynamic-loader.test.ts`. + Barrier: BEFORE + Discharges: FR-002 + Depends: T001 + +- [ ] T007 [P] Add a locality guard test asserting that neither new package writes to + or regenerates `schema/`, and that the envelope shape each package needs is + declared locally rather than in a shared schema module. + Files: `/test/envelope-shape-locality.test.ts`. + Barrier: BEFORE + Discharges: FR-005 (locality half) + Depends: T001, T002 + Contract: `package-boundary.md` §5 + +- [ ] T008 Add explicit `allowedDependenciesFor()` entries for `@adrkit/catalog-backstage` + (deps `@adrkit/core`, `picomatch`, `yaml`; devDeps `@types/bun`, `@types/picomatch`) + and `@adrkit/catalog-envelope` (deps `@adrkit/core`; devDeps `@types/bun`). + Do **not** amend the existing `@adrkit/cli` entry. + Files: `scripts/check-deps.ts`. + Barrier: BEFORE + Discharges: FR-003 + Depends: T001, T002 + Contract: `package-boundary.md` §2, §4 + +- [ ] T009 [US9] **Observed failing.** Introduce a dependency edge from `@adrkit/core` + (then `@adrkit/cli`, then a `schema/`-owning package) onto the adapter; run + `bun run check:deps`; observe the failure and record the exact emitted reason + string; remove the edge; observe the pass. Retain the failing inputs as a + permanent negative case. + Files: `scripts/check-deps.test.ts`, `/negative-cases/dep-core-to-adapter/`. + Barrier: BEFORE + Discharges: SC-015 + Depends: T008 + +- [ ] T010 [US9] **Observed failing.** Add `@adrkit/catalog-backstage` to the consumer's + dependencies; run `bun run check:deps`; observe the guard at + `scripts/check-deps.ts:175–182` emit `non-adapter workspace depends on an + adapter package`; record the exact string; remove; observe the pass. + Files: `scripts/check-deps.test.ts`, `/negative-cases/dep-consumer-to-adapter/`. + Barrier: BEFORE + Discharges: FR-044 (direction half, i) + Depends: T008, T009 + Contract: `package-boundary.md` §3 + +- [ ] T011 [US9] **Observed failing.** Add `@adrkit/catalog-envelope` to the adapter's + dependencies; run `bun run check:deps`; observe the guard at + `scripts/check-deps.ts:196–204` emit ` declares a dependency outside its + allowed public surface`; record the exact string; remove; observe the pass. + Files: `scripts/check-deps.test.ts`, `/negative-cases/dep-adapter-to-consumer/`. + Barrier: BEFORE + Discharges: FR-044 (direction half, ii) + Depends: T008, T010 + Contract: `package-boundary.md` §3 + +- [ ] T012 [US9] **Observed failing — closes the silent-unconstrained trap.** + `allowedDependenciesFor()` returns `undefined` for any package with no entry + (`scripts/check-deps.ts:151`), and the allowed-surface guard is then skipped + entirely — so a package with no entry passes `check:deps` no matter what it + declares. The only proof T008's entries actually exist is to add a disallowed + dependency to each new package and observe a violation. Do so for both packages + independently; record both reason strings; remove; observe both passes. + Files: `scripts/check-deps.test.ts`, `/negative-cases/dep-allowlist-present/`. + Barrier: BEFORE + Discharges: none — supports FR-003, FR-044 + Depends: T008, T011 + Contract: `package-boundary.md` §4 + +> **T009–T012 are serial by construction.** All four mutate `scripts/check-deps.ts` +> and/or package dependency blocks and re-run the same command. They must not be +> marked `[P]` and must not be split across worktrees. + +--- + +## Phase B — Barrier B itself: the fresh T014 → T014a cycle and the clause-5 freeze/audit + +**Barrier side: IS THE BARRIER.** Every task in this phase carries `[US1]`. +**No task in Phase B is marked `[P]`, and Phase B may not be split +freeze-now/audit-later** — SC-010 requires the corpus, the overlay, the expected +matches, and the recorded selection basis and size to be frozen in the **same cycle**, +with the audit recording its own hashes and its own PASS/FAIL. Phase B may run +concurrently with Phase A and with nothing else. + +- [ ] T013 [US1] Create the tracked evidence tree — `/README.md`, + `/frozen-expectations/`, `/accept-corpus-freeze/`. + These artifacts must be **git-tracked**: R5 mechanism 2 depends on CI being able + to re-derive their hashes, and ADR-0015 Condition of Acceptance 1 requires them + to be inspectable in the repository. + Barrier: IS THE BARRIER + Discharges: none — enables FR-053, FR-054, FR-055 + Depends: none + +- [ ] T014 [US1] **Record the accept-corpus selection basis and size before acting on + it.** Write `/accept-corpus-freeze/selection-basis.md` stating how the + corpus was chosen and how large it is, and how the populations documented in + `research.md` R14 were handled — specifically the invalid-`metadata.name` + population and the unsubstituted-skeleton placeholder-collision population. + Recording the basis *after* seeing which entities are convenient would defeat + the purpose. + Barrier: IS THE BARRIER + Discharges: FR-055 + Depends: T013 + +- [ ] T015 [US1] Author the maintainer-authored `adrkit.io/owned-paths` overlay at + `/accept-corpus-freeze/overlay.json`. This content is maintainer-authored, + never upstream-authored, and the record must say so. + Barrier: IS THE BARRIER + Discharges: FR-054 (overlay half) + Depends: T014 + +- [ ] T016 [US1] Author the expected path matches per canonical id at + `/accept-corpus-freeze/expected-paths.json`. These are **hand-derived + from the frozen contracts**, never produced by, checked against, or adjusted to + match any generator — no generator exists at this point, and Phase E may not + begin until T024. + Barrier: IS THE BARRIER + Discharges: FR-054 (expected-paths half) + Depends: T015 + +- [ ] T017 [US1] **Re-freeze the oracle (the fresh T014 step).** Write + `/frozen-expectations/frozen-expectation-set.json` containing + `derivedPathPatterns` in `compareCodeUnits`-sorted order — this ordering is the + correction the fresh cycle exists to make; input order is the defect — plus + `expectedByEntity`, `frozenAt`, and `contentHash`. + Barrier: IS THE BARRIER + Discharges: FR-053 + Depends: T014 + +- [ ] T018 [US1] Assemble `/accept-corpus-freeze/accept-corpus-freeze.json` + — `corpusRef`, `selectionBasis`, `size`, `overlay`, `expectedPaths`, `contentHash` + — **in the same cycle** as T014–T017. This artifact and the T017 oracle are + frozen together or not at all. + Barrier: IS THE BARRIER + Discharges: FR-054 (same-cycle freeze) + Depends: T015, T016, T017 + +- [ ] T019 [US1] **The independent audit (the T014a step).** A reviewer with no + authoring involvement in T014–T018 **recomputes** both content hashes from the + artifacts themselves — never copies the recorded values — confirms the + `derivedPathPatterns` ordering is `compareCodeUnits` and not input order, records + an **explicit adequacy finding** on the corpus (an integrity confirmation alone + does not satisfy clause 5(a)), and records the auditor's **own** PASS/FAIL. + Files: `/frozen-expectations/audit-record.json`, + `/accept-corpus-freeze/adequacy-audit.json`. + Barrier: IS THE BARRIER + Discharges: FR-057 (step (a) half), SC-010 + Depends: T018 + +- [ ] T020 [US1] **Observed failing.** Construct an oracle variant whose + `derivedPathPatterns` are in input order rather than `compareCodeUnits` order; + run the T019 audit against it; observe the audit return FAIL and record the + exact reason; restore the correct artifact; observe PASS. Retain the failing + variant at `/negative-cases/oracle-input-order/`. + Barrier: IS THE BARRIER + Discharges: none — supplies the ADR-0016 observation for FR-053 + Depends: T019 + +- [ ] T021 [US1] **Observed failing.** Construct an audit run that confirms hash + integrity but never reaches an adequacy finding; observe it recorded as FAIL + against SC-010 rather than silently accepted; restore; observe PASS. Retain at + `/negative-cases/audit-integrity-only/`. + Barrier: IS THE BARRIER + Discharges: none — supplies the ADR-0016 observation for SC-010 + Depends: T019, T020 + +- [ ] T022 [US1] Build the CI freeze-hash drift check — **R5 mechanism 2**. It + re-derives the content hashes of everything under `/frozen-expectations/` + and `/accept-corpus-freeze/` and fails the build on any drift. + Files: `scripts/check-freeze-hashes.ts`, `scripts/check-freeze-hashes.test.ts`, + `.github/workflows/ci.yml`. + Barrier: IS THE BARRIER + Discharges: none — implements R5 mechanism 2 + Depends: T019 + +- [ ] T023 [US1] **Observed failing.** Mutate a single byte of one frozen artifact; + run the T022 check; observe it fail and record the exact reason; restore the byte; + observe the pass. + Files: `scripts/check-freeze-hashes.test.ts`, + `/negative-cases/freeze-drift/`. + Barrier: IS THE BARRIER + Discharges: none — supplies the ADR-0016 observation for R5 mechanism 2 + Depends: T022 + +- [ ] T024 [US1] **BARRIER B CHECKPOINT — HARD GATE.** Confirm and record all three + R5 mechanisms simultaneously: + **(1) input absence** — no input manifest exists anywhere in the tree, and the + adapter contains no recursive walking or glob discovery that could substitute for + one (`input-manifest.md` §5); + **(2) hash match** — the T022 drift check is green in CI over both frozen trees; + **(3) ordering** — no comparison harness exists anywhere in the repository, in + any branch of this worktree, or in any scratch location. + Record the outcome as `BARRIER_B_CLEARED` at + `/barrier-b-checkpoint.json`, with the three confirmations stated + separately and the recording timestamped. + **No task in Phase E, F, or G may begin until this task is checked complete.** + Barrier: IS THE BARRIER + Discharges: none — gates FR-014, FR-023, FR-024, FR-034…FR-043, FR-050, FR-052, + FR-056, FR-057 (step b), FR-058 (report half), FR-059, FR-060, FR-061, + FR-063 (report half), SC-001, SC-002, SC-003, SC-009, SC-011, SC-012 + (demonstration half), SC-013, SC-016 + Depends: T019, T021, T023 + +--- + +## Phase C — Consumer package `@adrkit/catalog-envelope` + +**Barrier side: BEFORE.** Every task carries `[US8]`. The consumer validates an +envelope it is *given*; it never generates one. Its expected values come from +`snapshot-envelope.md`, not from the oracle or the clause-5 expectations, so the +whole phase is barrier-free under the R4 distinguishing test. Phase C may run +concurrently with Phase D. + +- [ ] T025 [P] [US8] Declare the consumer's **own independent** envelope shape at + `/src/envelope-shape.ts`. This duplication of the adapter's shape is + deliberate: a shared module would create exactly the coupling FR-005 forbids. + Barrier: BEFORE + Discharges: FR-005 (consumer half) + Depends: T002, T007 + Contract: `package-boundary.md` §5 + +- [ ] T026 [P] [US8] Add a guard test proving this feature leaves + `packages/core/src/affects/**` (including `packages/core/src/affects/catalog.ts`), + `packages/core/src/schema/adr.schema.ts`, and `schema/adr.schema.json` unchanged. + Files: `/test/no-core-schema-change.test.ts`. + Barrier: BEFORE + Discharges: FR-004 + Depends: T002 + +- [ ] T027 [P] [US8] Author the envelope fixtures under `/test/fixtures/` — + one malformed fixture per validation step (five), plus mutated-payload, stale, + foreign-repository, and valid. + Barrier: BEFORE + Discharges: none — enables FR-045…FR-049 + Depends: T002 + +- [ ] T028 [US8] Implement the **five ordered validation steps** at + `/src/validate/index.ts`, each rejecting at its own step with its own + distinct reason. + Barrier: BEFORE + Discharges: FR-045 + Depends: T025, T027 + Contract: `snapshot-envelope.md` §2 + +- [ ] T029 [US8] **Observed failing, per step, individually.** Drive each of the five + malformed fixtures through T028; observe five *distinct* failures; record each + exact reason string; confirm no fixture fails at a step earlier than its target; + restore; observe the pass. + Files: `/test/validate-steps.test.ts`, + `/negative-cases/consumer-steps/`. + Barrier: BEFORE + Discharges: none — supplies the ADR-0016 observations for SC-014 + Depends: T028 + +- [ ] T030 [US8] Add the ordering guard: no `derivedPaths` value is read before all + five steps pass, and any attempt to derive before validation is refused. + Files: `/src/validate/index.ts`, `/test/no-early-read.test.ts`. + Barrier: BEFORE + Discharges: FR-046 + Depends: T028, T029 + +- [ ] T031 [P] [US8] Implement digest recomputation at `/src/digest/index.ts`, + with every claim scoped to **integrity**, never correctness. Observe the + mutated-payload fixture failing; record the reason; restore; observe the pass. + Barrier: BEFORE + Discharges: FR-041 + Depends: T027, T028 + Contract: `snapshot-envelope.md` §3 + +- [ ] T032 [P] [US8] Implement staleness as **exact revision inequality** at + `/src/identity/staleness.ts` — never an ordering, chronological, or + ancestry comparison. Observe the stale fixture failing; record the reason; + restore; observe the pass. + Barrier: BEFORE + Discharges: FR-047 + Depends: T027, T028 + Contract: `snapshot-envelope.md` §4 + +- [ ] T033 [P] [US8] Implement repository identity handling at + `/src/identity/repository.ts`: an envelope whose repository does not + match is **rejected as misidentified**; a *valid* envelope from a *different* + repository is **accepted**, and a query against it simply returns no matches. + These two outcomes must not be conflated. Observe both. + Barrier: BEFORE + Discharges: FR-048 + Depends: T027, T028 + Contract: `snapshot-envelope.md` §5, §6 + +- [ ] T034 [US8] Implement `CatalogSnapshot`-shaped derivation at + `/src/snapshot/index.ts`, reachable only after all five steps, the + digest recomputation, the staleness check, and the identity check have passed. + Barrier: BEFORE + Discharges: FR-049 + Depends: T030, T031, T032, T033 + +- [ ] T035 [US8] Add the integrity-is-not-correctness framing to the consumer's public + surface and README, plus a test asserting no correctness-claim language appears + in the package's exported types, error strings, or documentation. + Files: `/README.md`, `/test/no-correctness-claim.test.ts`. + Barrier: BEFORE + Discharges: FR-058 (consumer framing half), SC-012 (framing half) + Depends: T031, T034 + +- [ ] T036 [US8] SC-014 close-out: a consolidated test asserting every malformed + envelope is rejected **at its own step** with its own reason, and that no + `derivedPaths` value was read in any rejected case. + Files: `/test/sc-014.test.ts`. + Barrier: BEFORE + Discharges: SC-014 + Depends: T029, T030, T034 + Contract: `snapshot-envelope.md` §7 + +- [ ] T037 [US8] FR-044 behavioural half: assert the consumer imports nothing from + `packages/adapters/**` at build time or runtime — a build-graph assertion, not + only a `package.json` inspection. + Files: `/test/no-adapter-import.test.ts`. + Barrier: BEFORE + Discharges: FR-044 (behavioural half) + Depends: T034 + Contract: `package-boundary.md` §3 + +--- + +## Phase D — Adapter pure validators + +**Barrier side: BEFORE.** Settled 2026-08-04 by maintainer decision in favour of the +narrower reading of ADR-0020 clause 6: clause 6 does not reach unit-level validator +execution whose expected values come from frozen contracts. Every expected value in +Phase D is traceable to `admissibility.md`, `input-manifest.md`, `entity-identity.md`, +`owned-paths-annotation.md`, or `glob-dialect.md` — never to the oracle or the clause-5 +expectations. No task in this phase assembles the pipeline, computes an ownership +result for a descriptor-sourced entity end to end, or produces an envelope. + +Phase D may run concurrently with Phase C. It divides into three disjoint module +slices — **D2** (input boundary), **D1a** (admissibility and identity), **D1b** +(ownership and glob) — which touch disjoint directories under `/src/`. + +> **Constraint on all Phase D slices:** no Phase D task may modify +> `/package.json` or `/tsconfig.json`. Those files are owned by +> Phase A. Any Phase D need for a new dependency must be raised back to T008 rather +> than edited in place, or concurrent worktrees will conflict. + +### D2 — Input boundary (`[US2]`) + +- [ ] T038 [US2] Implement the closed input-manifest schema at + `/src/manifest/schema.ts`: any unrecognized top-level field is rejected + rather than ignored. + Barrier: BEFORE + Discharges: FR-006 + Depends: T001 + Contract: `input-manifest.md` §1 + +- [ ] T039 [US2] Enforce single-repository binding: one manifest describes exactly one + repository, and a manifest naming more than one is rejected. + Files: `/src/manifest/schema.ts`, `/test/manifest-single-repo.test.ts`. + Barrier: BEFORE + Discharges: FR-007 + Depends: T038 + Contract: `input-manifest.md` §1 + +- [ ] T040 [US2] Implement the three version and capability rejections — + `unsupported-manifest-version`, `unsupported-snapshot-version`, + `unsupported-capability` — each **observed failing** with its own exact reason, + then restored and observed passing. + Files: `/src/manifest/version.ts`, + `/test/manifest-version.test.ts`, + `/negative-cases/manifest-version/`. + Barrier: BEFORE + Discharges: FR-008 + Depends: T038 + Contract: `input-manifest.md` §2 + +- [ ] T041 [US2] Obtain repository identity and revision through separate git tooling + at `/src/repository/identity.ts` — **never** from a descriptor + annotation or any content under the repository being described. + **Fixture constraint (`input-manifest.md` §3.1):** the mismatch fixture must be a + standalone scratch `git init` repository, **never** a `git worktree add` linked + worktree, because a linked worktree shares remote configuration with its parent + and would silently pass. The current working directory *is* such a worktree. + Files: `/src/repository/identity.ts`, + `/test/repository-identity.test.ts`. + Barrier: BEFORE + Discharges: FR-009 + Depends: T038 + Contract: `input-manifest.md` §3, §3.1 + +- [ ] T042 [US2] Enforce **exact string equality** on repository identity and revision; + a partial, prefix, or normalized match aborts the operation. Observe a + near-miss revision failing; record the reason; restore; observe the pass. + Files: `/src/repository/identity.ts`, + `/test/repository-exact-match.test.ts`, + `/negative-cases/repository-mismatch/`. + Barrier: BEFORE + Discharges: FR-010 + Depends: T041 + Contract: `input-manifest.md` §3 + +- [ ] T043 [P] [US2] Verify every declared per-source digest **before any entity is + processed**; a mismatch or a missing source yields `incomplete-required-source`. + Observe it failing; record the reason; restore; observe the pass. + Files: `/src/manifest/digests.ts`, + `/test/manifest-digests.test.ts`, + `/negative-cases/incomplete-required-source/`. + Barrier: BEFORE + Discharges: FR-011 + Depends: T038 + Contract: `input-manifest.md` §4 + +- [ ] T044 [P] [US2] Implement **two-stage** path validation at + `/src/manifest/paths.ts`: a lexical rejection stage, then a confined + realpath stage. Both stages observed failing independently, each with its own + reason; restored; observed passing. + Files: `/src/manifest/paths.ts`, `/test/manifest-paths.test.ts`, + `/negative-cases/path-validation/`. + Barrier: BEFORE + Discharges: FR-012 + Depends: T038 + Contract: `input-manifest.md` §4.1 + +- [ ] T045 [P] [US2] Close the input boundary: assert the adapter never follows + `Location.spec.targets`, never invokes a Backstage processor, plugin, or + ingestion path, and never performs recursive walking or glob discovery to find + descriptors. Include the `Location` worked example as a test. + Files: `/src/manifest/boundary.ts`, + `/test/input-boundary.test.ts`. + Barrier: BEFORE + Discharges: FR-013 + Depends: T038 + Contract: `input-manifest.md` §5, §6 + +- [ ] T046 [US2] SC-008 close-out: a consolidated test asserting every input reaching + the adapter arrived through the declared manifest and through no other route. + Files: `/test/sc-008.test.ts`. + Barrier: BEFORE + Discharges: SC-008 + Depends: T042, T043, T044, T045 + +### D1a — Admissibility and identity (`[US3]`) + +- [ ] T047 [P] [US3] Implement descriptor reading at `/src/descriptor/read.ts` + using `yaml`'s `parseDocument` with `uniqueKeys` left at its default `true`. + Observe `duplicate-yaml-key` and `invalid-yaml-syntax` emerging as **two distinct + outcomes**, never collapsed into one; record both reason strings; restore; + observe the pass. + Files: `/src/descriptor/read.ts`, `/test/descriptor-read.test.ts`, + `/negative-cases/yaml-read/`. + Barrier: BEFORE + Discharges: none — supports FR-023, which is discharged at T071 + Depends: T001 + +- [ ] T048 [US3] Enforce that admissibility is evaluated **before** canonicalization, + structurally rather than by convention. + Files: `/src/admissibility/index.ts`, + `/test/admissibility-ordering.test.ts`. + Barrier: BEFORE + Discharges: FR-015 + Depends: T047 + Contract: `admissibility.md` §4, §4.1 + +- [ ] T049 [US3] Implement the **four** admissibility field validators at + `/src/admissibility/validators.ts`, each **separately attributed** so a + rejection names which validator rejected. Observe each of the four failing + independently; record four distinct reason strings; restore; observe the pass. + Files: `/src/admissibility/validators.ts`, + `/test/admissibility-validators.test.ts`, + `/negative-cases/admissibility-validators/`. + Barrier: BEFORE + Discharges: FR-016 + Depends: T048 + Contract: `admissibility.md` §2, §2.1 + +- [ ] T050 [US3] Implement the separator rule: an identity string with **two or more** + separators is rejected; one with **no** separator is evaluated by the suffix + predicate alone, so a bare `v1` passes. Observe both branches. + Files: `/src/admissibility/separator.ts`, + `/test/admissibility-separator.test.ts`. + Barrier: BEFORE + Discharges: FR-017 + Depends: T049 + Contract: `admissibility.md` §3 + +- [ ] T051 [US3] Implement `inadmissible-descriptor` classification and its failure + semantics. + Files: `/src/admissibility/classify.ts`, + `/test/admissibility-classify.test.ts`. + Barrier: BEFORE + Discharges: FR-018 + Depends: T049, T050 + Contract: `admissibility.md` §5 + +- [ ] T052 [US3] Ensure every inadmissibility record identifies **all three** of: + the descriptor path, the failing field, and the rejecting validator — and is + distinguishable from a `duplicate-canonical-id` record. + Files: `/src/admissibility/classify.ts`, + `/test/admissibility-record.test.ts`. + Barrier: BEFORE + Discharges: FR-020 + Depends: T051 + Contract: `admissibility.md` §5, §5.1 + +- [ ] T053 [US3] Enforce that **no inadmissible descriptor participates in a + uniqueness comparison** — duplicate detection is not a validity test and must + never be reached by an inadmissible input. + Files: `/src/admissibility/index.ts`, + `/test/admissibility-excluded-from-uniqueness.test.ts`. + Barrier: BEFORE + Discharges: FR-019 + Depends: T052 + Contract: `admissibility.md` §6 + +- [ ] T054 [US3] **Observed failing — permanent negative case.** Construct a descriptor + that is simultaneously **inadmissible and canonically unique**. Observe it + produce `inadmissible-descriptor` and **not** `duplicate-canonical-id`; record + both the emitted reason and the absence of the wrong one; restore; observe the + pass. This fixture is retained permanently — it is the only thing that + distinguishes T053 from an accident of ordering. + Files: `/test/inadmissible-and-unique.test.ts`, + `/negative-cases/inadmissible-and-unique/`. + Barrier: BEFORE + Discharges: FR-021 + Depends: T053 + +- [ ] T055 [US3] Implement **two-step** canonicalization at + `/src/identity/canonicalize.ts`: default-namespace substitution first, + then lowercase the **entire** identity string — not merely the name component. + Barrier: BEFORE + Discharges: FR-022 + Depends: T054 + Contract: `entity-identity.md` §1 + +- [ ] T056 [US3] SC-004 close-out: a consolidated test asserting inadmissibility is + decided before canonical identity is computed, for every admissibility failure + mode. + Files: `/test/sc-004.test.ts`. + Barrier: BEFORE + Discharges: SC-004 + Depends: T055 + Contract: `admissibility.md` §8 + +### D1b — Ownership and glob (`[US4]`) + +- [ ] T057 [P] [US4] Derive ownership from the `adrkit.io/owned-paths` annotation + **alone** at `/src/ownership/derive.ts`. No inference from the + descriptor's file location, its parent directory, the repository root, or any + other signal. + Barrier: BEFORE + Discharges: FR-025 + Depends: T001 + Contract: `owned-paths-annotation.md` §1 + +- [ ] T058 [US4] Implement the **five** ordered annotation decode steps at + `/src/ownership/annotation.ts`, each with its own distinct rejection + reason. Observe each of the five failing independently; record five distinct + reason strings; restore; observe the pass. + Files: `/src/ownership/annotation.ts`, + `/test/annotation-decode.test.ts`, + `/negative-cases/annotation-decode/`. + Barrier: BEFORE + Discharges: FR-026 + Depends: T057 + Contract: `owned-paths-annotation.md` §1 + +- [ ] T059 [US4] **Observed failing — permanent negative case.** Step 2's string-scalar + check runs against the **raw YAML node**, before `JSON.parse`. Therefore the + annotation value `["[]"]` — a YAML sequence, not a string — must yield + `annotation-value-not-a-string`, and must **never** be silently coerced into + `explicit-empty`. Observe the correct reason; observe the absence of the wrong + one; restore; observe the pass. Retain permanently. + Files: `/test/annotation-step2-raw-node.test.ts`, + `/negative-cases/annotation-sequence-coercion/`. + Barrier: BEFORE + Discharges: FR-027 + Depends: T058 + Contract: `owned-paths-annotation.md` §1 + +- [ ] T060 [US4] Keep the **three** ownership states distinct and never conflated, and + decide `explicit-empty` on the **decoded** value — so `'[]'`, `'[ ]'`, and + `'[\n]'` all qualify — never by raw-string equality. + Files: `/src/ownership/states.ts`, + `/test/ownership-states.test.ts`. + Barrier: BEFORE + Discharges: FR-028 + Depends: T059 + Contract: `owned-paths-annotation.md` §1 + +- [ ] T061 [US4] SC-005 close-out: a consolidated test over the three ownership states. + Files: `/test/sc-005.test.ts`. + Barrier: BEFORE + Discharges: SC-005 + Depends: T060 + +- [ ] T062 [US4] SC-006 close-out: a consolidated test over the five annotation decode + steps, each rejecting at its own step with its own reason. + Files: `/test/sc-006.test.ts`. + Barrier: BEFORE + Discharges: SC-006 + Depends: T060, T061 + +- [ ] T063 [P] [US4] Implement the restricted glob dialect and freeze the engine and + its options at `/src/glob/dialect.ts`. The `picomatch` version must be + **read at runtime from the resolved dependency**, never transcribed into a + literal — a transcribed version silently goes stale. + Barrier: BEFORE + Discharges: FR-029 + Depends: T001 + Contract: `glob-dialect.md` §1, §6 + +- [ ] T064 [US4] Implement the **fifteen** ordered rules with first-match-wins + semantics at `/src/glob/validate.ts`. + Barrier: BEFORE + Discharges: FR-030 + Depends: T063 + Contract: `glob-dialect.md` §3 + +- [ ] T065 [US4] **Observed failing for rules 1–14 only.** For each of rules 1 through + 14, supply a pattern that violates *that* rule and no earlier one; observe the + rule fire; record its exact rejection reason; restore; observe the pass. + **Rule 15 (`invalid-glob-compile-failure`) is a defensive backstop that its own + contract states is "expected to never occur in practice."** Its `accepted` + outcome *is* exercised by valid patterns reaching it, but its rejection reason is + **not required**, and rule 15 not firing is **conformant** and **must not be + reported as a coverage gap** in any artifact this feature produces. + Files: `/test/glob-rules.test.ts`, `/negative-cases/glob-rules/`. + Barrier: BEFORE + Discharges: SC-007 + Depends: T064 + Contract: `glob-dialect.md` §3 + +- [ ] T066 [US4] Assert rule-specific rejection reasons hold when a **mixed batch** of + patterns is validated, each pattern evaluated in isolation so no pattern's + outcome influences another's. + Files: `/test/glob-mixed-batch.test.ts`. + Barrier: BEFORE + Discharges: FR-031 + Depends: T065 + Contract: `glob-dialect.md` §3 + +- [ ] T067 [US4] Compile each pattern **once per run** and reuse the compiled matcher, + so validation and matching cannot diverge. + Files: `/src/glob/dialect.ts`, `/test/glob-compile-once.test.ts`. + Barrier: BEFORE + Discharges: FR-032 + Depends: T066 + Contract: `glob-dialect.md` §6 + +- [ ] T068 [US4] Sort `derivedPaths` with `compareCodeUnits` + (`packages/core/src/ordering/index.ts:12`) and deduplicate. + Files: `/src/glob/order.ts`, `/test/glob-order.test.ts`. + Barrier: BEFORE + Discharges: FR-033 + Depends: T067 + +--- + +## Phase E — Assembled generator: pipeline, atomicity, envelope + +**Barrier side: BEHIND.** Every task in this phase lists **T024** in its `Depends` +line. **No task in Phase E is marked `[P]`, and — per anti-verdict 1 — nothing +whatsoever runs concurrently with Phase E.** Phase E is where generator-derived output +first exists; the whole point of Barrier B is that this moment comes after the freeze +and the audit. + +- [ ] T069 [US2] Compose the Phase D units into `/src/pipeline.ts` in fixed + stage order: manifest → repository → digests → descriptor read → admissibility → + canonicalization → ownership → glob → envelope. Composition only; no new + validation logic. + Barrier: BEHIND + Discharges: none — enables FR-014, FR-023, FR-024, FR-034…FR-043 + Depends: T024, T046, T056, T068 + +- [ ] T070 [US2] Set `completeness.wholeCatalog === false` unconditionally, in every + envelope, on every path. There is no configuration, flag, or input that can make + it `true`. + Files: `/src/envelope/completeness.ts`, + `/test/completeness-always-false.test.ts`. + Barrier: BEHIND + Discharges: FR-014 + Depends: T024, T069 + +- [ ] T071 [US5] Enforce **global canonical uniqueness over every ref**, emitting + `duplicate-canonical-id`, `duplicate-canonical-ref`, and `duplicate-yaml-key` as + three distinct classes. First-wins and last-wins resolution are forbidden — a + collision aborts. + Files: `/src/identity/uniqueness.ts`, + `/test/uniqueness.test.ts`. + Barrier: BEHIND + Discharges: FR-023 + Depends: T024, T069 + Contract: `entity-identity.md` §3 + > **[NEEDS CLARIFICATION]** — carried forward unresolved from `plan.md` and + > `research.md` R12 (`spec.md`:1275; `data-model.md` §5 line 190): what populates + > `allRefs` beyond `canonicalId`. **Do not resolve by guess.** Consequence to + > record at this task: the reachability of `duplicate-canonical-ref` outside + > synthetic fixtures is unknown, and SC-003 may be satisfiable only synthetically + > for that one class. + +- [ ] T072 [US5] Establish that **overlap between distinct canonical ids is not a + collision** — two entities may derive overlapping paths, and no exclusive winner + is selected. + Files: `/src/identity/overlap.ts`, `/test/overlap.test.ts`. + Barrier: BEHIND + Discharges: FR-024 + Depends: T024, T071 + Contract: `entity-identity.md` §4 + +- [ ] T073 [US5] Implement whole-operation abort: any fatal trigger aborts the entire + operation, exits non-zero, and leaves **no usable partial output** — no partial + envelope, no partial file, no truncated stream. + Files: `/src/failure/abort.ts`, `/test/abort.test.ts`. + Barrier: BEHIND + Discharges: FR-034 + Depends: T024, T069 + Contract: `atomic-fail-closed.md` §1, §2 + +- [ ] T074 [US5] Declare the closed **fifteen**-value fatal trigger enumeration as a + string-literal union at `/src/failure/triggers.ts`. This feature's count + is **fifteen** — spike 009's fourteen **plus** `inadmissible-descriptor`, added by + ADR-0015 Condition of Acceptance 2. The enumeration, verbatim from + `atomic-fail-closed.md` §4 (lines 68–81): + `duplicate-canonical-id` | `duplicate-canonical-ref` | `duplicate-yaml-key` | + `invalid-yaml-syntax` | `invalid-manifest-shape` | `invalid-annotation-shape` | + `invalid-annotation-parse` | `invalid-pattern` | `unsupported-manifest-version` | + `unsupported-snapshot-version` | `unsupported-capability` | `repository-mismatch` | + `incomplete-required-source` | `inadmissible-descriptor` | `other-invalid-input`. + **No artifact this feature produces may state "fourteen" as this feature's + trigger count.** + Barrier: BEHIND + Discharges: FR-035 + Depends: T024, T073 + Contract: `atomic-fail-closed.md` §4 + +- [ ] T075 [US5] Implement `other-invalid-input` as a **deliberate, always-present + backstop** — never removed as unreachable, never treated as dead code, and never + used to absorb a case that has its own class. + Files: `/src/failure/triggers.ts`, + `/test/backstop-trigger.test.ts`. + Barrier: BEHIND + Discharges: FR-036 + Depends: T024, T074 + Contract: `atomic-fail-closed.md` §4.2 + +- [ ] T076 [US5] Enforce that each abort carries **exactly one** trigger class, and + that it is the **correct** one — including for the collapsible pairs the contract + identifies as most at risk of being merged. + Files: `/src/failure/classify.ts`, + `/test/trigger-classification.test.ts`. + Barrier: BEHIND + Discharges: FR-037 + Depends: T024, T075 + Contract: `atomic-fail-closed.md` §4.3 + +- [ ] T077 [US5] Assert **whole-operation atomicity over a mixed batch** — a batch + containing both valid and invalid entities produces no output at all. This is a + *separate property* from the per-rule tests in Phase D, which is precisely why + `plan.md` places it behind the barrier under the R4 definition even though the + distinguishing test alone might not have. + Files: `/test/sc-002-mixed-batch.test.ts`. + Barrier: BEHIND + Discharges: SC-002 + Depends: T024, T076 + Contract: `atomic-fail-closed.md` §2 + +- [ ] T078 [US5] Drive **all fifteen** trigger classes through the **full assembled + pipeline**, each **observed failing first** with its exact reason string, each + failing input retained permanently. + Files: `/test/sc-003-all-triggers.test.ts`, + `/negative-cases/triggers/`. + Barrier: BEHIND + Discharges: SC-003 + Depends: T024, T077 + > **[NEEDS CLARIFICATION]** consequence carried from T071: if `allRefs` is + > populated only by `canonicalId`, `duplicate-canonical-ref` may be reachable + > only via a synthetic fixture. Record that plainly here rather than presenting a + > synthetic case as a corpus-derived one. + +- [ ] T079 [US6] Emit the versioned envelope as the **only** output: no side files, no + logs presented as output, no auxiliary artifacts. + Files: `/src/envelope/write.ts`, `/test/envelope-only.test.ts`. + Barrier: BEHIND + Discharges: FR-038 + Depends: T024, T069 + +- [ ] T080 [US6] Implement the envelope's declared fields and **exactly five** fields + per `entities[]` record. The flatter triple shape is forbidden. + Files: `/src/envelope/shape.ts`, `/test/envelope-shape.test.ts`. + Barrier: BEHIND + Discharges: FR-039 + Depends: T024, T079 + Contract: `snapshot-envelope.md` §1 (see also `data-model.md` §9, §10) + +- [ ] T081 [US6] Compute the envelope digest using `@adrkit/core`'s `canonicalStringify` + (`packages/core/src/fingerprint/index.ts:16`, exported at + `packages/core/src/index.ts:24`), SHA-256, rendered as 64 lowercase hex + characters. **Never** use the same-named function at + `packages/evaluator/src/report/serialize.ts:38` — it has a different signature + `(root, pretty = false)` and importing it crosses a disallowed dependency + boundary. Every digest claim must travel with its scope qualification: for the + envelope's closed scalar domain the bytes are *equivalent to* RFC 8785 / JCS + output; **no document may claim `canonicalStringify` is a general-purpose RFC 8785 + implementation.** + Files: `/src/envelope/digest.ts`, `/test/envelope-digest.test.ts`. + Barrier: BEHIND + Discharges: FR-040 + Depends: T024, T080 + Contract: `package-boundary.md` §2.2 + +- [ ] T082 [US6] Maintain the provenance boundary in the envelope: upstream-authored + descriptor content and maintainer-authored overlay content are recorded as + distinct provenances and never merged into an undifferentiated whole. + Files: `/src/envelope/provenance.ts`, + `/test/envelope-provenance.test.ts`. + Barrier: BEHIND + Discharges: FR-043 + Depends: T024, T081 + +- [ ] T083 [US2] Produce **byte-identical** output across repeated runs over identical + input. + Files: `/test/byte-identical.test.ts`. + Barrier: BEHIND + Discharges: FR-042 + Depends: T024, T082 + +- [ ] T084 [US2] Assert determinism across **at least three** runs, on the **accept + path and the reject path alike** — a deterministic rejection is as much a + requirement as a deterministic envelope. + Files: `/test/sc-001-determinism.test.ts`. + Barrier: BEHIND + Discharges: SC-001 + Depends: T024, T083 + +- [ ] T085 [US6] SC-013 close-out: exactly one envelope is produced; each + `entities[]` record carries exactly five fields; the recorded digest matches an + **independent** recomputation, not the generator's own. + Files: `/test/sc-013.test.ts`. + Barrier: BEHIND + Discharges: SC-013 + Depends: T024, T081, T084 + +- [ ] T086 [US2] SC-009 close-out — the **rescoped** criterion (spike 009's SC-010, + rescoped by ADR-0020 clause 3): every required pass yields either a populated + envelope **or** a deterministic, atomic, correctly-classified rejection; at least + one pass over the **frozen accept corpus** yields a populated envelope; and a + fabricated or hand-edited envelope never satisfies it. + Files: `/test/sc-009.test.ts`. + Barrier: BEHIND + Discharges: SC-009 + Depends: T024, T084, T085 + +--- + +## Phase F — Clause-5 step (b): post-output comparison + +**Barrier side: BEHIND.** Every task carries `[US7]` and lists **T024** in `Depends`. +**Phase F is fully serial and — per anti-verdict 2 — may not be started early "so it +is ready."** Authoring the comparison harness before the freeze and the audit would +collapse ADR-0020 clause 5's two distinct steps into one, which is the exact failure +the barrier exists to prevent. + +- [ ] T087 [US7] **Author the comparison harness now, and not before.** Write + `scripts/compare-accept-corpus.ts` and `scripts/compare-accept-corpus.test.ts`. + Record explicitly, at `/comparison/harness-provenance.md`, that no + comparison harness existed prior to T024's confirmation and prior to Phase E + producing output — this is R5 mechanism 3, and the record is the only artifact + that carries it. + Barrier: BEHIND + Discharges: none — implements R5 mechanism 3 + Depends: T024, T086 + +- [ ] T088 [US7] Diff the derived ownership for **every annotated entity in the frozen + accept corpus** against the frozen expectations, requiring **zero false positives + and zero false negatives**. + Files: `scripts/compare-accept-corpus.ts`, + `/comparison/diff-report.json`. + Barrier: BEHIND + Discharges: FR-056, SC-011 + Depends: T024, T087 + +- [ ] T089 [US7] **Observed failing.** Introduce a deliberate mismatch into the + comparison input; observe the gate FAIL and record the exact reason; remove the + mismatch; observe the PASS. Retain the mismatch as a permanent negative case at + `/negative-cases/comparison-mismatch/`. + Barrier: BEHIND + Discharges: none — supplies the ADR-0016 observation for SC-011 + Depends: T024, T088 + +- [ ] T090 [US7] Record step (b)'s **own** hashes and **own** PASS/FAIL at + `/comparison/step-b-record.json`. Step (b) inherits nothing from step + (a): it recomputes, and it renders its own verdict. + Barrier: BEHIND + Discharges: FR-057 (step (b) half) + Depends: T024, T089 + +- [ ] T091 [US7] **Prohibition guard: expectations are never amended to fit output.** + Assert that every hash under `/frozen-expectations/` and + `/accept-corpus-freeze/` is unchanged from its Phase B value, across + the whole of Phase E and Phase F. A comparison that passes because the + expectations moved is not a passing comparison. + Files: `scripts/check-freeze-hashes.test.ts`, + `/comparison/expectations-unchanged.json`. + Barrier: BEHIND + Discharges: none — enforces the clause-5 prohibition + Depends: T024, T090 + +- [ ] T092 [US7] Reporting-honesty close-out: assert that no Phase F artifact presents + the populated, digest-verified envelope as evidence of **correctness**. A digest + establishes integrity. The comparison establishes agreement with a maintainer-authored + expectation set. Neither establishes that the adapter is correct, and no artifact + may imply otherwise. + Files: `/comparison/reporting-honesty.md`, + `scripts/compare-accept-corpus.test.ts`. + Barrier: BEHIND + Discharges: FR-058 (report half), FR-063 (report half), SC-012 (demonstration half) + Depends: T024, T091 + +--- + +## Phase G — Clean clone, offline operation, clause-8 CI gate + +**Barrier side: BEHIND.** Every task lists **T024** in `Depends`. Phase G runs after +Phase F completes. + +- [ ] T093 [US9] Verify from a **clean clone** that build, typecheck, lint, and + `bun test` are all green with both new packages present, and that network access + is permitted **only** during `bun install --frozen-lockfile`. + Files: `.github/workflows/ci.yml` (job `clean-clone-builds`). + Barrier: BEHIND + Discharges: FR-050 + Depends: T024, T092 + +- [ ] T094 [US2] Run the generator with network access **actively denied** — no + credential present, no service reachable — and confirm it completes. Confirm + further that it does **not** degrade to a networked path when one happens to be + available: the offline path is the only path. + Files: `/test/offline-run.test.ts`, `.github/workflows/ci.yml`. + Barrier: BEHIND + Discharges: FR-052 + Depends: T024, T093 + +- [ ] T095 [US9] SC-016 close-out: the evidence must be a **denial**, not an absence of + observed calls. An absence of calls is consistent with a network path that simply + was not taken. Cite the denial mechanism from spike 009's + `scale-and-security-measurement.md` §5 **at its original location**; do not copy + it into this feature's contracts. + Files: `/test/sc-016.test.ts`. + Barrier: BEHIND + Discharges: SC-016 + Depends: T024, T094 + +- [ ] T096 [P] [US8] Cross-package end-to-end check: an envelope written by the + generator is validated successfully by the consumer, with **no import edge in + either direction** between the two packages — the envelope travels as data. + Files: `scripts/cross-package-envelope.test.ts`. + Barrier: BEHIND + Discharges: none — supports FR-044 + Depends: T024, T037, T086 + Contract: `package-boundary.md` §3 + +- [ ] T097 [P] [US9] Assert that **no B/C/D comparison heuristic from spike 009** + appears in the adapter — not as an inferred behaviour, not as an authoritative + rule, not as a default, and not as an opt-in. Implement as a repository-wide + check, and observe it failing by temporarily reintroducing one such heuristic; + record the reason; remove; observe the pass. + Files: `scripts/check-no-spike-heuristics.ts`, + `scripts/check-no-spike-heuristics.test.ts`, + `/negative-cases/spike-heuristic/`. + Barrier: BEHIND + Discharges: FR-061 + Depends: T024, T086 + +- [ ] T098 [US9] Build the ADR-0020 **clause-8 executable CI gate**, tied to clause 5, + and **observe it failing before observing it passing**. ADR-0020's own frontmatter + assertion is **inert** (`status: 'inert'`, `reason: 'assertions-compile.engine-absent'`) + and **must not be cited as enforcement** — the gate must be a real CI check. + Files: `scripts/check-clause8-gate.ts`, `scripts/check-clause8-gate.test.ts`, + `.github/workflows/ci.yml`, `/negative-cases/clause8-gate/`. + Barrier: BEHIND + Discharges: FR-060 + Depends: T024, T090, T095 + +- [ ] T099 [US9] Repository-wide observed-failing-first close-out: enumerate **every** + check this feature introduced, and confirm for each that a failing observation was + recorded, with its exact reason string, and that a permanent negative case is + retained. A check appearing only in the passing column is a coverage gap and must + be reported as one. + Files: `/observed-failing-register.md`, + `scripts/check-observed-failing-register.test.ts`. + Barrier: BEHIND + Discharges: FR-059 + Depends: T024, T098 + > **[NEEDS CLARIFICATION]** — carried forward unresolved from `plan.md` and + > `research.md`:427 (`spec.md`:1290): the **release-evidence component of + > ADR-0012 gate 4**. **Do not resolve by guess.** Consequence to record at this + > task: gate 4 remains not-yet-testable regardless of this feature's outcome, and + > must be recorded as unmet rather than as passed or failed. + +- [ ] T100 [US9] Final honesty close-out. Assert, as executable checks where possible + and as a recorded finding otherwise, that: + (i) no artifact this feature produced claims ADR-0014 rung 2 or rung 3; + (ii) no task, script, workflow, or document schedules, implies, or prepares a + release; + (iii) ADR-0012 gate 3's outcome is recorded **as observed**, never claimed in + advance; + (iv) ADR-0012 gate 4 is recorded **unmet and not yet testable**, per the carried + clarification at T099; + (v) no artifact asserts what Backstage as a running system does — every claim is + scoped to what a pure validator predicate returns at the pinned commit + `1121a4facd9e321179d0402c3f355e4a649e84d9`; + (vi) only corpus **data** is described as third-party; the validation never is. + Files: `/honesty-close-out.md`, + `scripts/check-honesty-close-out.test.ts`. + Barrier: BEHIND + Discharges: none — supports FR-062, SC-017 + Depends: T024, T099 + +--- + +## Coverage — all 63 FRs and all 17 SCs + +`plan.md` distributes every FR and SC across phases A–G. Six identifiers are assigned +by `plan.md` to **two phases each**, because each names two genuinely separable +obligations. They are represented below as **split discharges with named halves**, so +every FR and SC has exactly one row and every row lists every task that discharges it. +This is stated plainly rather than silently deduplicated to force a fabricated 1:1 — +the split is in `plan.md`, and hiding it would be the dishonest option. + +The six split identifiers: **FR-005**, **FR-044**, **FR-057**, **FR-058**, **FR-063**, +**SC-012**. All other 57 FRs and 16 SCs are discharged by exactly one task. + +### Functional requirements + +| FR | Discharged by | Phase | +|---|---|---| +| FR-001 | T001 | A | +| FR-002 | T006 | A | +| FR-003 | T008 | A | +| FR-004 | T026 | C | +| FR-005 | T007 *(locality half)* + T025 *(consumer half)* | A + C | +| FR-006 | T038 | D | +| FR-007 | T039 | D | +| FR-008 | T040 | D | +| FR-009 | T041 | D | +| FR-010 | T042 | D | +| FR-011 | T043 | D | +| FR-012 | T044 | D | +| FR-013 | T045 | D | +| FR-014 | T070 | E | +| FR-015 | T048 | D | +| FR-016 | T049 | D | +| FR-017 | T050 | D | +| FR-018 | T051 | D | +| FR-019 | T053 | D | +| FR-020 | T052 | D | +| FR-021 | T054 | D | +| FR-022 | T055 | D | +| FR-023 | T071 | E | +| FR-024 | T072 | E | +| FR-025 | T057 | D | +| FR-026 | T058 | D | +| FR-027 | T059 | D | +| FR-028 | T060 | D | +| FR-029 | T063 | D | +| FR-030 | T064 | D | +| FR-031 | T066 | D | +| FR-032 | T067 | D | +| FR-033 | T068 | D | +| FR-034 | T073 | E | +| FR-035 | T074 | E | +| FR-036 | T075 | E | +| FR-037 | T076 | E | +| FR-038 | T079 | E | +| FR-039 | T080 | E | +| FR-040 | T081 | E | +| FR-041 | T031 | C | +| FR-042 | T083 | E | +| FR-043 | T082 | E | +| FR-044 | T002 *(placement)* + T010, T011 *(direction, i & ii)* + T037 *(behavioural)* | A + C | +| FR-045 | T028 | C | +| FR-046 | T030 | C | +| FR-047 | T032 | C | +| FR-048 | T033 | C | +| FR-049 | T034 | C | +| FR-050 | T093 | G | +| FR-051 | T003 | A | +| FR-052 | T094 | G | +| FR-053 | T017 | B | +| FR-054 | T015 *(overlay)* + T016 *(expected paths)* + T018 *(same-cycle freeze)* | B | +| FR-055 | T014 | B | +| FR-056 | T088 | F | +| FR-057 | T019 *(step (a) half)* + T090 *(step (b) half)* | B + F | +| FR-058 | T035 *(consumer framing half)* + T092 *(report half)* | C + F | +| FR-059 | T099 | G | +| FR-060 | T098 | G | +| FR-061 | T097 | G | +| FR-062 | T004 | A | +| FR-063 | T004 *(documentation half)* + T092 *(report half)* | A + F | + +### Success criteria + +| SC | Discharged by | Phase | +|---|---|---| +| SC-001 | T084 | E | +| SC-002 | T077 | E | +| SC-003 | T078 | E | +| SC-004 | T056 | D | +| SC-005 | T061 | D | +| SC-006 | T062 | D | +| SC-007 | T065 | D | +| SC-008 | T046 | D | +| SC-009 | T086 | E | +| SC-010 | T019 | B | +| SC-011 | T088 | F | +| SC-012 | T035 *(integrity-is-not-correctness framing half)* + T092 *(demonstration half)* | C + F | +| SC-013 | T085 | E | +| SC-014 | T036 | C | +| SC-015 | T009 | A | +| SC-016 | T095 | G | +| SC-017 | T004 | A | + +### Per-phase totals + +| Phase | Tasks | Range | `[P]` | FRs discharged | SCs discharged | +|---|---|---|---|---|---| +| A | 12 | T001–T012 | 6 | FR-001, 002, 003, 005 *(half)*, 044 *(halves)*, 051, 062, 063 *(half)* | SC-015, SC-017 | +| B | 12 | T013–T024 | 0 | FR-053, 054, 055, 057 *(half)* | SC-010 | +| C | 13 | T025–T037 | 6 | FR-004, 005 *(half)*, 041, 044 *(half)*, 045, 046, 047, 048, 049, 058 *(half)* | SC-012 *(half)*, SC-014 | +| D | 31 | T038–T068 | 6 | FR-006…013, 015…022, 025…033 | SC-004, 005, 006, 007, 008 | +| E | 18 | T069–T086 | 0 | FR-014, 023, 024, 034…040, 042, 043 | SC-001, 002, 003, 009, 013 | +| F | 6 | T087–T092 | 0 | FR-056, 057 *(half)*, 058 *(half)*, 063 *(half)* | SC-011, SC-012 *(half)* | +| G | 8 | T093–T100 | 2 | FR-050, 052, 059, 060, 061 | SC-016 | +| **Total** | **100** | **T001–T100** | **20** | **63** | **17** | + +--- + +## Dependency graph + +`T024` is the hard gate. Every task in phases E, F, and G names it. This graph — not +the prose above it — is what makes it mechanically impossible to begin behind-barrier +work before the barrier is cleared. + +``` + ┌─────────────────────── BEFORE THE BARRIER ────────────────────────┐ + + PHASE A (before) PHASE B (IS THE BARRIER) + ──────────────── ──────────────────────── + T001 ─┬─> T003 T013 ──> T014 ─┬─> T015 ──> T016 ─┐ + T002 ─┘ │ │ │ + T001 ──> T004 └─> T017 ──────────┤ + T002 ──> T005 │ + T001 ──> T006 T018 <──────────┘ + T001,T002 ──> T007 │ + T001,T002 ──> T008 v + │ T019 ─┬─> T020 ──> T021 + v │ + T009 ──> T010 ──> T011 ──> T012 (strictly serial) └─> T022 ──> T023 + │ + T019, T021, T023 ────────────────────> T024 <─────┘ + ╔═══════════╗ + ║ BARRIER ║ + ║ B CLEARED ║ + ╚═══════════╝ + PHASE C (before, needs A) PHASE D (before, needs A) + ───────────────────────── ───────────────────────── + T002,T007 ──> T025 ─┐ D2: T038 ─┬─> T039 + T002 ──> T026 │ ├─> T040 + T002 ──> T027 ─┴─> T028 ─┬─> T029 ─┐ ├─> T041 ──> T042 ─┐ + ├─> T030 <┘ ├─> T043 ──────────┤ + ├─> T031 ─┐ ├─> T044 ──────────┤ + ├─> T032 │ └─> T045 ──────────┤ + └─> T033 ─┤ T046 <───────┘ + T034 <─┘ + ├──> T035 D1a: T047 ──> T048 ──> T049 ──> T050 + ├──> T036 ──> T051 ──> T052 ──> T053 + └──> T037 ──> T054 ──> T055 ──> T056 + + D1b: T057 ──> T058 ──> T059 ──> T060 + ├─> T061 ──> T062 + T063 ──> T064 ──> T065 ──> T066 + ──> T067 ──> T068 + + └──────────────────────────────────────────────────────────────────┘ + + ══════════════════════════ NOTHING BELOW MAY START BEFORE T024 ══════════════════════════ + + PHASE E (behind — fully serial; nothing runs concurrently with it) + ────────────────────────────────────────────────────────────────── + T024 + T046 + T056 + T068 ──> T069 ─┬─> T070 + ├─> T071 ──> T072 + ├─> T073 ──> T074 ──> T075 ──> T076 ──> T077 ──> T078 + └─> T079 ──> T080 ──> T081 ──> T082 ──> T083 ──> T084 + │ │ + └──────> T085 <──────────┘ + │ + T086 + PHASE F (behind — fully serial) + ─────────────────────────────── + T024 + T086 ──> T087 ──> T088 ──> T089 ──> T090 ──> T091 ──> T092 + + PHASE G (behind) + ──────────────── + T024 + T092 ──> T093 ──> T094 ──> T095 ─┐ + T024 + T037 + T086 ──> T096 [P] │ + T024 + T086 ──> T097 [P] │ + T024 + T090 + T095 ─────────> T098 <────┘ + │ + T099 ──> T100 +``` + +### Gate edges, stated explicitly + +- **T024 → every one of T069–T100.** No exception. Phase E, F, and G tasks each carry + `T024` in their `Depends` line, so no dispatcher can schedule one of them while the + barrier checkpoint is unchecked. +- **T019, T021, T023 → T024.** The checkpoint cannot be reached until the audit has + rendered its own verdict (T019), the two audit-failure modes have been observed + (T020, T021), and the drift check has been observed failing (T023). +- **T046, T056, T068 → T069.** The assembled pipeline may not be composed until all + three Phase D slices are closed out. +- **T086 → T087.** The comparison harness is authored only after Phase E has produced + output. This edge *is* R5 mechanism 3. +- **T090, T095 → T098.** The clause-8 gate cannot be built until step (b) has rendered + its own verdict and the network denial has been observed as a denial. + +--- + +## Parallel opportunities and worktree dispatch + +### The adopted sequence + +``` +[A ∥ B] → [C ∥ D] → E → F → G +``` + +### Recorded but NOT adopted + +Under the stricter reading of ADR-0020 clause 6 — that clause 6 reaches unit-level +validator execution — the sequence would have been `[A ∥ B] → [B ∥ C] → D → E → F → G`, +placing Phase D behind the barrier. That reading was **settled 2026-08-04 by maintainer +decision in favour of the narrower reading**, and Phase D sits before the barrier. +The counterfactual is recorded here because the decision was contested, not because it +is available to be re-adopted mid-implementation. + +### The three anti-verdicts — hard rules, not guidance + +1. **Nothing runs concurrently with Phase E.** Not Phase F "getting started", not + Phase G's CI wiring, not documentation. Phase E is where generator output first + exists; concurrency there is how a barrier gets crossed by accident. +2. **Phase F may not be started early "so it is ready."** Authoring the comparison + harness before the freeze and audit collapses ADR-0020 clause 5's two distinct steps + into one. T087's provenance record exists precisely to make an early start visible. +3. **Phase B may not be split freeze-now / audit-later.** SC-010 requires the corpus, + the overlay, the expected matches, and the recorded selection basis and size to be + frozen in the **same cycle**, with the audit recording its own hashes and its own + PASS/FAIL. A freeze whose audit arrives later is not an audited freeze. + +### Safe to hand to concurrent worktree sessions + +| Slice | Tasks | Owns | Why it is safe | +|---|---|---|---| +| **Worktree A** | T001–T012 | `` and `` skeletons, `scripts/check-deps.ts` | Touches no evidence artifact and no `src/` module logic. | +| **Worktree B** | T013–T024 | `/**`, `scripts/check-freeze-hashes.ts` | Touches no package source. Runs concurrently with A. | +| **Worktree C** | T025–T037 | `/src/**`, `/test/**` | Disjoint from all adapter source. Needs A complete. | +| **Worktree D2** | T038–T046 | `/src/{manifest,repository}/**` | Disjoint directories. Needs A complete. | +| **Worktree D1a** | T047–T056 | `/src/{descriptor,admissibility,identity}/**` | Disjoint directories. Needs A complete. | +| **Worktree D1b** | T057–T068 | `/src/{ownership,glob}/**` | Disjoint directories. Needs A complete. | + +**Constraint binding all four of C, D2, D1a, D1b:** no task in these slices may modify +`/package.json`, `/tsconfig.json`, `/package.json`, +`/tsconfig.json`, or `scripts/check-deps.ts`. Those files are owned by +Phase A. A newly discovered dependency need must be raised back to T008 and merged +through Phase A, not edited in place — otherwise concurrent worktrees will conflict on +the manifest files and, worse, may each pass `check:deps` locally while together +violating it. + +### Must be held serial + +| Held serial | Why | +|---|---| +| **T009 → T010 → T011 → T012** within Phase A | All four mutate `scripts/check-deps.ts` and/or package dependency blocks and re-run the same command. Interleaving them makes each observation unattributable. | +| **All of Phase B (T013 → T024)** | Anti-verdict 3. The freeze and the audit are one cycle. The audit must be performed by someone with no authoring involvement in T014–T018, which is a *reviewer* constraint, not a parallelism opportunity. | +| **All of Phase E (T069 → T086)** | Anti-verdict 1. Nothing runs concurrently with Phase E. | +| **All of Phase F (T087 → T092)** | Anti-verdict 2, plus each task consumes the previous task's verdict. | +| **Phase G apart from T096 and T097** | T093 → T094 → T095 share the CI workflow and the offline-execution environment; T098 → T099 → T100 are a verdict chain. Only T096 and T097 are genuinely disjoint. | + +--- + +## Implementation strategy + +This is **not** an MVP-and-iterate feature, and the task list must not be read as one. +The barrier makes the ordering load-bearing: a phase started early is not merely +premature, it invalidates the evidence the phase was supposed to produce. + +The strategy is: + +1. **Establish the ground truth first, under audit** (Phase B), concurrently with + getting the workspace boundaries enforceable (Phase A). Neither depends on the other. +2. **Build every pure validator whose expected values come from frozen contracts** + (Phases C and D), concurrently, in disjoint module slices. +3. **Assemble** (Phase E), alone, in one place, with nothing else moving. +4. **Compare** (Phase F), with a harness that provably did not exist before the freeze. +5. **Harden and gate** (Phase G). + +Throughout: every check is observed failing before it counts as coverage (ADR-0016); +every count is verified in source before it is written down; and no artifact claims +more than rung 1. + +### Carried unknowns — unresolved, and not to be resolved by guess + +| # | Unknown | Carried at | Consequence | +|---|---|---|---| +| 1 | What populates `allRefs` beyond `canonicalId` (`research.md` R12; `spec.md`:1275; `data-model.md` §5 line 190) | **T071**, with the consequence restated at **T078** | The reachability of `duplicate-canonical-ref` outside synthetic fixtures is unknown. SC-003 may be satisfiable only synthetically for that one trigger class, and that must be reported as such. | +| 2 | The release-evidence component of ADR-0012 gate 4 (`research.md`:427; `spec.md`:1290) | **T099**, with the recording obligation at **T100** | Gate 4 remains not-yet-testable regardless of this feature's outcome, and is recorded **unmet** — never as passed, never as failed. | + +A third open question — whether ADR-0020 clause 6 reaches unit-level validator +execution (`research.md` R4) — was **resolved on 2026-08-04** by maintainer decision in +favour of the narrower reading. It is recorded here as **resolved**, not open, and is +the reason Phase D sits before the barrier. + +### Counting facts this document depends on — each verified in source + +| Count | Value | Read at | +|---|---|---| +| Fatal trigger classes **for this feature** | **fifteen** — spike 009's fourteen plus `inadmissible-descriptor` (ADR-0015 Condition of Acceptance 2) | `contracts/atomic-fail-closed.md` §4, lines 68–81 | +| Glob dialect ordered rules | **fifteen**, of which conformance covers rules **1–14 firing**; rule 15 `invalid-glob-compile-failure` is a backstop its own contract calls "expected to never occur in practice", and its non-firing **is conformant and is not a coverage gap** | `specs/009-catalog-binding-viability/contracts/glob-dialect.md` §3, lines 33–74; `plan.md` Phase D; `quickstart.md` §5.2 | +| Consumer validation steps | five | `snapshot-envelope.md` §2 | +| Admissibility field validators | four | `contracts/admissibility.md` §2 | +| Manifest version/capability rejections | three, plus `incomplete-required-source` = four manifest-request-level rejections | `contracts/atomic-fail-closed.md` §6; `input-manifest.md` §2 | +| Ownership states | three | `owned-paths-annotation.md` §1 | +| Annotation decode steps | five | `owned-paths-annotation.md` §1 | +| Path validation stages | two | `input-manifest.md` §4.1 | +| Canonicalization steps | two | `entity-identity.md` §1 | +| Fields per `entities[]` record | five | `data-model.md` §9, §10 | +| `community-plugins` corpus @ `92e9e4e09c76cc57f3475029b73e5ec84498a459` | **156 descriptor files / 167 entity documents** — file counts and document counts are different quantities | `research.md` R14, lines 571–572 | +| `rhdh-plugins` corpus @ `3b355ddfedb23c6656bd9effc8510f9926b765c1` | **38 descriptor files / 39 entity documents** — the 38/39 figure holds only under exact `catalog-info.yaml` basename matching | `research.md` R14, lines 571–572 | +| Spike-009 contracts in the adoption register | eleven — 5 adopted unchanged, 3 adopted with delta, 3 excluded | `contracts/README.md` §2 | + +The corpus figures are **reference figures for fixture design**, not scale targets, and +must not be presented as either throughput evidence or coverage evidence. + +--- + +**Generating this task list marked no task complete and performed no implementation.** +Every checkbox above is unchecked. No package exists. No check has been observed +failing. No envelope exists. Nothing in this document is a report.