diff --git a/packages/catalog-envelope/README.md b/packages/catalog-envelope/README.md index 0a4265ea..9a41e170 100644 --- a/packages/catalog-envelope/README.md +++ b/packages/catalog-envelope/README.md @@ -35,20 +35,106 @@ Two consequences worth naming, because they are easy to invert: --- -## Status: this package validates nothing yet +## What it does, in the order it does it + +The order is the contract, not an implementation detail +([`snapshot-envelope.md`](../../specs/009-catalog-binding-viability/contracts/snapshot-envelope.md) +§2; `spec.md` FR-045, FR-046). Each stage runs only after every stage above it +has passed, and each rejects with its own named reason. + +| # | Stage | Rejects as | +|---|---|---| +| 1 | Parses as JSON at all | `invalid-json` | +| 2 | The complete envelope shape, every field the correct JSON type, **at every nesting level** | `missing-or-wrong-required-field` | +| 3 | The frozen matcher contract by **exact value** — `schemaVersion`, the whole `globDialect` object, the exact `capabilities` tuple | `unrecognized-schema-or-dialect-or-capability` | +| 4 | Every `sources[]` digest present, correctly typed, and matching its file's actual bytes | `missing-source-digest` | +| 5 | `completeness.identityOnly === false` | `identity-only-true` | +| — | **Then** independent digest recomputation | `digest-mismatch` | +| — | **Then** staleness, as exact revision inequality | `stale-revision` | +| — | **Then** repository identity | `repository-identity-mismatch` | +| — | **Only then** `CatalogSnapshot`-shaped derivation | refused outright without an admission token | + +```ts +import { admitEnvelope, deriveCatalogSnapshot } from '@adrkit/catalog-envelope'; + +const admission = admitEnvelope(envelopeText, { + sourceBaseDir: 'path/to/descriptors', + expectedRepositoryId: 'github.com/acme/services', + expectedRevision: '<40-hex-sha>', +}); + +if (admission.outcome === 'refused') { + // `refusedAt` names the stage, `reason` the category, `detail` the specifics. + throw new Error(`${admission.refusedAt}: ${admission.reason} — ${admission.detail}`); +} + +const { snapshot, derivedFrom } = deriveCatalogSnapshot(admission.admitted); +``` -Feature `010-catalog-backstage` is partially built. What exists here today is the -package's **placement** and its **dependency boundary**. The five ordered -validation steps, digest recomputation, staleness evaluation, repository-identity -handling, and snapshot derivation are requirements on a later phase, recorded in -[`specs/010-catalog-backstage/`](../../specs/010-catalog-backstage/). They are not +`deriveCatalogSnapshot` accepts `unknown` and **throws** on anything without an +admission token. That is deliberate: a returned rejection can be ignored and the +caller can go on to read `derivedPaths` anyway, and FR-046 does not permit that. + +### Three things that are easy to get backwards + +- **Staleness is exact inequality, never an ordering.** A commit SHA is an opaque + identifier with no ordering available without git-ancestry data, which is out + of scope. Any revision other than the configured expected-current one is stale + — never "older than". There is no `<` anywhere in that module. The expectation + is also keyed to a repository: repository A's expected revision says nothing + about an envelope describing repository B. +- **Step 5 reads one boolean.** Whether an envelope is partial/identity-only is + determined **solely** from `completeness.identityOnly`, never by scanning the + entity list. An envelope whose entities are *all* `annotation-absent` with + `identityOnly: false` is accepted — absent annotations are a valid, expected + state. +- **The lossy mapping is not repaired.** `CatalogSnapshotEntity` has no + `ownershipState`, so `explicit-empty` and `annotation-absent` both derive an + empty `paths`. The distinction stays on the envelope; it is not smuggled into + the core type, and changing `CatalogSnapshot` to carry it is out of scope. + +## What the digest does and does not establish + +Both statements travel with every mention of the digest check, in code, in tests, +and in evidence. That is a requirement (`spec.md` FR-041), not editorial caution. + +- **Accidental-corruption and naive-mutation detection only.** It does not resist + an adversary who mutates content and also recomputes the same digest with the + same algorithm. A cryptographically-signed tamper-evidence mechanism — with its + own key-management, trust-anchor, and deterministic-output questions — is an + explicitly open question this feature does not attempt. +- **Integrity, not correctness.** A semantically wrong envelope can carry a + perfectly valid self-digest. + +The canonicalization primitive is `canonicalStringify` from `@adrkit/core`, not a +second implementation written here — a second definition of "canonical" could +drift silently from the generator's, which is the failure the digest exists to +detect. For the envelope's closed scalar domain its bytes are *equivalent to* +RFC 8785/JCS output; that equivalence is scoped to the domain and is not a claim +that `canonicalStringify` is a general-purpose RFC 8785 implementation. + +## Status + +Phase C of feature `010-catalog-backstage` is implemented: the five ordered +validation steps, digest recomputation, staleness, repository identity and +isolation, and gated derivation. Phases B, D, E, F and G are recorded in +[`specs/010-catalog-backstage/`](../../specs/010-catalog-backstage/) and are not behaviour this package has. +Every check here was **observed failing before it was relied on** +([ADR-0016](../../docs/adr/0016-require-every-check-to-be-observed-failing-before-it-counts-as-coverage.md)): +each of the five steps was deleted in turn, the digest was replaced with the +declared value, staleness was rewritten as an ordering comparison, and both +repository outcomes were conflated in each direction — with the failures captured +verbatim under +[`evidence/negative-cases/`](../../specs/010-catalog-backstage/evidence/negative-cases/). +One of those observations found a real defect in a test that had been passing. + Per [ADR-0014](../../docs/adr/0014-stage-phase-landing-evidence-across-a-three-rung-validation-ladder.md), -stated in that record's own vocabulary: rung-1 evidence covers only what exists, -which is placement and boundary. This package is **not** `reference-verified` -(rung 2) and **not** `externally validated` (rung 3), and claims neither. Any -verification performed here is maintainer-owned, which is not external, +stated in that record's own vocabulary: this is rung-1 evidence. This package is +**not** `reference-verified` (rung 2) and **not** `externally validated` (rung 3), +and claims neither. Every fixture is synthetic and hand-authored, with no external +adopter involved; every observation is maintainer-owned, which is not external, third-party, or community adoption and will not be described as such. No release is authorized or prepared; ADR-0020 clause 9 defers that decision to a diff --git a/packages/catalog-envelope/src/digest/index.ts b/packages/catalog-envelope/src/digest/index.ts new file mode 100644 index 00000000..df9a4c2f --- /dev/null +++ b/packages/catalog-envelope/src/digest/index.ts @@ -0,0 +1,102 @@ +/** + * Independent recomputation of the envelope's self-digest + * (`snapshot-envelope.md` §3; `data-model.md` §12; `spec.md` FR-040, FR-041). + * + * ## What this check proves, stated before anything else + * + * **Accidental-corruption and naive-mutation detection only.** It does not + * resist an adversary who mutates content and also recomputes the same digest + * with the same algorithm. A cryptographically-signed tamper-evidence + * mechanism — with its own key-management, trust-anchor, and + * deterministic-output questions — is an explicitly **open question** that + * neither this package nor feature `010-catalog-backstage` attempts. + * + * **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. A + * `match` here means the bytes are the bytes the generator wrote. It does not + * mean the ownership those bytes record is the ownership that should have been + * recorded, and no output of this module may be read as having established + * that. + * + * Both statements travel with every mention of this check, in code, in tests, + * and in evidence. That is a requirement of FR-041, not editorial caution. + * + * ## Why the primitive is imported rather than written here + * + * `canonicalStringify` comes from `@adrkit/core` + * (`packages/core/src/fingerprint/index.ts`, re-exported from + * `packages/core/src/index.ts`), per `data-model.md` §12's implementation + * instruction. Writing a second canonicalizer would create a second definition + * of "canonical" that could drift silently from the generator's — which is + * precisely the failure this digest exists to detect. + * + * Note that this is *not* the same-named function at + * `packages/evaluator/src/report/serialize.ts`, which has a different signature + * and is explicitly excluded by `package-boundary.md` §2.1. + * + * For the envelope's closed scalar domain — strings, booleans, null, and + * bounded non-negative integers — these bytes are *equivalent to* RFC 8785/JCS + * output. That equivalence is scoped to the domain; no claim is made that + * `canonicalStringify` is a general-purpose RFC 8785 implementation + * (`package-boundary.md` §2.2). + */ + +import { createHash } from 'node:crypto'; +import { canonicalStringify } from '@adrkit/core'; +import type { SnapshotEnvelope } from '../envelope-shape.ts'; +import { envelopeOf, type StructurallyValidEnvelope } from '../validate/index.ts'; + +/** `data-model.md` §12. */ +export interface DigestCheckResult { + readonly declaredDigest: string; + readonly recomputedDigest: string; + readonly outcome: 'match' | 'digest-mismatch'; + /** + * The exact scope of what a `match` establishes, carried on the result itself + * so that a caller serializing this into evidence cannot drop it (FR-041). + */ + readonly guaranteeScope: typeof DIGEST_GUARANTEE_SCOPE; +} + +export const DIGEST_GUARANTEE_SCOPE = + 'Detects accidental corruption and naive mutation only. Does not resist an adversary who mutates content and recomputes the same digest. A digest match proves integrity, not correctness: a semantically wrong envelope can carry a valid self-digest.' as const; + +/** + * The canonical form the digest is taken over: every field of the envelope + * **including `schemaVersion`**, **excluding only `digest` itself**, with keys + * recursively sorted by `compareCodeUnits`, arrays left in declaration order, + * compact separators, and `undefined` fields omitted. + */ +export function canonicalFormOf(envelope: SnapshotEnvelope): string { + const { digest: _excluded, ...rest } = envelope; + return canonicalStringify(rest); +} + +/** SHA-256 over the UTF-8 bytes of the canonical form; 64 lowercase hex. */ +export function recomputeEnvelopeDigest(envelope: SnapshotEnvelope): string { + return createHash('sha256').update(canonicalFormOf(envelope), 'utf8').digest('hex'); +} + +/** + * Recompute and compare. + * + * The parameter type is `StructurallyValidEnvelope`, not `SnapshotEnvelope`, and + * that is the point: `snapshot-envelope.md` §2 forbids attempting *any* digest + * check before all five validation steps pass, and the only way to obtain this + * token is to have passed them. The ordering is enforced by the signature + * rather than by a comment asking the caller to be careful. + * + * The declared digest is **never** trusted unconditionally — it is recomputed + * here from the envelope's own remaining fields and compared. + */ +export function checkEnvelopeDigest(validated: StructurallyValidEnvelope): DigestCheckResult { + const envelope = envelopeOf(validated); + const recomputedDigest = recomputeEnvelopeDigest(envelope); + return { + declaredDigest: envelope.digest, + recomputedDigest, + outcome: recomputedDigest === envelope.digest ? 'match' : 'digest-mismatch', + guaranteeScope: DIGEST_GUARANTEE_SCOPE, + }; +} diff --git a/packages/catalog-envelope/src/envelope-shape.ts b/packages/catalog-envelope/src/envelope-shape.ts new file mode 100644 index 00000000..ef89b691 --- /dev/null +++ b/packages/catalog-envelope/src/envelope-shape.ts @@ -0,0 +1,210 @@ +/** + * The consumer's **own, independent** declaration of the snapshot envelope's + * wire shape. + * + * **This duplicates the generator's declaration on purpose.** `spec.md` FR-005 + * and `contracts/package-boundary.md` §5 require it. A shared type module would + * be an import edge between `@adrkit/catalog-backstage` and this package, and + * §3 of that contract forbids an edge in either direction. More to the point: + * if both sides derived their view of the envelope from one declaration, a + * generator that changed the shape would change it on both sides at once, and + * this package's structural validation would be comparing the generator against + * itself rather than checking it. + * + * The cost is accepted and named: these two declarations can diverge, and + * nothing but this package's validation failing will say so. That failure is + * the intended signal — not drift to be refactored away. + * + * **Nothing here is derived from the generator.** Every constant below is + * transcribed from a contract frozen in `specs/`: + * `specs/009-catalog-binding-viability/contracts/snapshot-envelope.md` §1–§2 + * (carried forward unchanged by `specs/010-catalog-backstage/contracts/README.md` + * §2) and `specs/010-catalog-backstage/data-model.md` §9–§10. + */ + +/** + * The only `schemaVersion` this consumer accepts, validated by **exact value** + * (`snapshot-envelope.md` §2 step 3). + */ +export const ENVELOPE_SCHEMA_VERSION = '1'; + +/** + * The frozen matcher contract, validated by **deep exact value** — never by + * `version` alone. `snapshot-envelope.md` §2 step 3 states plainly that a + * `globDialect.version`-only check is *insufficient*: an `engine` of + * `"minimatch"`, or an `options` object with `dot: true` / `nocase: true` / + * `nonegate: false`, each has to fail. + */ +export const FROZEN_GLOB_DIALECT = { + engine: 'picomatch', + version: '4.0.5', + options: { dot: false, nocase: false, nonegate: true }, +} as const; + +/** + * The exact capability tuple, validated by **deep equality on the whole tuple**. + * `snapshot-envelope.md` §2 step 3: a per-entry-membership-only check is + * *insufficient*, so an empty array, an extra element, or any other string each + * has to fail. + */ +export const FROZEN_CAPABILITIES = ['pathOwnership'] as const; + +/** + * Exactly three ownership states; there is no fourth + * (`data-model.md` §7.2). + * + * `explicit-empty` and `annotation-absent` both yield an empty `derivedPaths` + * and are **not** equivalent. The discriminator is this field; the distinction + * is never inferred from `derivedPaths`. + */ +export const RECOGNIZED_OWNERSHIP_STATES = [ + 'explicit-paths', + 'explicit-empty', + 'annotation-absent', +] as const; + +export type OwnershipState = (typeof RECOGNIZED_OWNERSHIP_STATES)[number]; + +/** + * The one digest algorithm the envelope's `sources[]` may declare + * (`snapshot-envelope.md` §1; `data-model.md` §9's `EnvelopeSource`). + */ +export const RECOGNIZED_DIGEST_ALGORITHM = 'sha256'; + +/** + * **A genuine gap, marked rather than papered over.** + * + * `snapshot-envelope.md` §2 step 2 requires "a recognized `provenance`", but no + * contract frozen under feature `010-catalog-backstage` enumerates what + * `provenance` values are recognized. `data-model.md` §10 types the field as a + * bare `string`, and `spec.md` FR-043 gives it a *semantic* requirement — it + * must distinguish upstream-authored descriptor content from maintainer-authored + * annotation overlay — without naming the values that carry the distinction. + * Spike 009's `data-model.md` line 195 does enumerate three values + * (`community-plugins-real`, `rhdh-plugins-real`, `synthetic`), but those name + * that spike's three corpus *passes*, which is a different axis from FR-043's + * authored-by distinction, so they cannot simply be adopted here. + * + * This consumer therefore recognizes any **non-empty string**, and rejects a + * `provenance` that is missing, not a string, or empty. Inventing a closed + * vocabulary here would be worse than this: it would make the consumer reject + * conformant generator output on the strength of a value domain no contract + * ever froze. The narrower check is adopted deliberately, and the gap is + * reported rather than closed by guesswork. + */ +export function isRecognizedProvenance(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +export interface EnvelopeRepository { + readonly id: string; + readonly revision: string; +} + +export interface EnvelopeGlobOptions { + readonly dot: boolean; + readonly nocase: boolean; + readonly nonegate: boolean; +} + +export interface EnvelopeGlobDialect { + readonly engine: string; + readonly version: string; + readonly options: EnvelopeGlobOptions; +} + +export interface EnvelopeCompleteness { + readonly wholeCatalog: boolean; + readonly identityOnly: boolean; +} + +export interface EnvelopeSource { + readonly path: string; + readonly digestAlgorithm: string; + /** + * Optional **in the declared type only**, because the whole point of step 4 is + * that an envelope can arrive with this omitted. A `sources` entry that is + * structurally well-formed but omits its digest passes step 2 and is rejected + * at step 4 (`snapshot-envelope.md` §2). Typing it as required would move that + * rejection to step 2 and collapse two distinct malformation kinds into one. + */ + readonly digest?: string; +} + +/** + * The identity projection an envelope carries: `{ canonicalId, allRefs }` and + * nothing else. + * + * `snapshot-envelope.md` §1 is explicit that the pre-lowercase authoring inputs + * (`rawKind` / `rawNamespace` / `rawName` / `fixtureAuthoredAliasRefs`) are + * deliberately **not** serialized — they are already fully captured by these two + * fields. + */ +export interface SerializedEntityIdentity { + readonly canonicalId: string; + readonly allRefs: readonly string[]; +} + +export interface SerializedSourceDocument { + readonly sourcePath: string; + readonly documentIndexInFile: number; +} + +/** + * **Exactly five fields** (`data-model.md` §10; `snapshot-envelope.md` §1). + * + * A flatter `canonicalId` / `refs` / `paths` triple is forbidden, and so is the + * generator's full internal record. The on-disk envelope and this declared type + * are meant to be one shape, not two that drift. + */ +export interface SnapshotEntityRecord { + readonly identity: SerializedEntityIdentity; + readonly ownershipState: OwnershipState; + readonly derivedPaths: readonly string[]; + readonly sourceDocument: SerializedSourceDocument; + readonly provenance: string; +} + +/** + * **Nine top-level fields** (`data-model.md` §9). + * + * One envelope per generation pass, never merged, single-repository only. + */ +export interface SnapshotEnvelope { + readonly schemaVersion: string; + readonly repository: EnvelopeRepository; + readonly generatorVersion: string; + readonly globDialect: EnvelopeGlobDialect; + readonly capabilities: readonly string[]; + readonly completeness: EnvelopeCompleteness; + readonly sources: readonly EnvelopeSource[]; + readonly entities: readonly SnapshotEntityRecord[]; + /** SHA-256 over the canonical form of every field above, 64 lowercase hex. */ + readonly digest: string; +} + +/** + * The nine top-level field names, in the order `snapshot-envelope.md` §1 + * declares them. Exported so a test can assert a **specific observed list** + * rather than assert an absence and call that coverage (ADR-0016 clause 3). + */ +export const ENVELOPE_TOP_LEVEL_FIELDS = [ + 'schemaVersion', + 'repository', + 'generatorVersion', + 'globDialect', + 'capabilities', + 'completeness', + 'sources', + 'entities', + 'digest', +] as const; + +/** The five field names every `SnapshotEntityRecord` carries — no more, no fewer. */ +export const ENTITY_RECORD_FIELDS = [ + 'identity', + 'ownershipState', + 'derivedPaths', + 'sourceDocument', + 'provenance', +] as const; diff --git a/packages/catalog-envelope/src/identity/repository.ts b/packages/catalog-envelope/src/identity/repository.ts new file mode 100644 index 00000000..2a08fc68 --- /dev/null +++ b/packages/catalog-envelope/src/identity/repository.ts @@ -0,0 +1,137 @@ +/** + * Repository identity — **two outcomes that must never be conflated** + * (`snapshot-envelope.md` §5 and §6; `data-model.md` §13 and §14; + * `spec.md` FR-048). + * + * ## The line this module draws + * + * | Situation | Correct behaviour | + * |---|---| + * | A consumer expected **exactly one** repository and is handed an envelope declaring a different one | **Reject**, naming the mismatch (§5) | + * | A tool deliberately holds **several** independently-generated, individually-valid single-repository envelopes and queries them scoped to one | **Accept all of them**; the query returns only the scoped repository's entities (§6) | + * + * Conflating these turns a legitimate multi-repository index into a rejection, + * or turns a genuine mismatch into a silent filter that returns an empty result + * and looks like "no matches". Both failures are quiet, which is why the two + * paths are separate exported functions with separate result types rather than + * one function with a flag. + * + * **Isolation is a property of the query, not an error condition.** Neither + * envelope is rejected in the §6 case; both remain independently valid. + * Generation itself never produces a federated or multi-repository snapshot — + * that remains an absolute constraint on the generator, and this module + * describes only downstream consumer behaviour across separately-generated + * files. + */ + +import type { SnapshotEntityRecord } from '../envelope-shape.ts'; +import { envelopeOf, type StructurallyValidEnvelope } from '../validate/index.ts'; + +// --------------------------------------------------------------------------- +// §5 — mismatch and reject, for a consumer that expected exactly one repository +// --------------------------------------------------------------------------- + +export interface RepositoryIdentityCheckResult { + readonly expectedRepositoryId: string | undefined; + readonly declaredRepositoryId: string; + /** + * `not-configured` is distinct from `ok` for the same reason it is in the + * staleness check: an unchecked envelope must not render identically to a + * checked one. + */ + readonly outcome: 'ok' | 'repository-identity-mismatch' | 'not-configured'; + readonly detail: string; +} + +/** + * Reject an envelope declaring a repository other than the single one this + * consumer expects. + * + * Takes a `StructurallyValidEnvelope` because `snapshot-envelope.md` §2 forbids + * any repository-identity check before all five validation steps pass. + */ +export function checkRepositoryIdentity( + validated: StructurallyValidEnvelope, + expectedRepositoryId?: string, +): RepositoryIdentityCheckResult { + const declaredRepositoryId = envelopeOf(validated).repository.id; + + if (expectedRepositoryId === undefined) { + return { + expectedRepositoryId: undefined, + declaredRepositoryId, + outcome: 'not-configured', + detail: `no expected repository id was configured, so ${declaredRepositoryId} was not compared against anything`, + }; + } + + if (declaredRepositoryId !== expectedRepositoryId) { + return { + expectedRepositoryId, + declaredRepositoryId, + outcome: 'repository-identity-mismatch', + detail: `envelope declares repository ${declaredRepositoryId}, which is not the repository ${expectedRepositoryId} this consumer expects`, + }; + } + + return { + expectedRepositoryId, + declaredRepositoryId, + outcome: 'ok', + detail: `envelope declares the expected repository ${declaredRepositoryId}`, + }; +} + +// --------------------------------------------------------------------------- +// §6 — filter and isolate, for a consumer deliberately holding several +// --------------------------------------------------------------------------- + +/** `data-model.md` §14. Every loaded envelope here is individually valid. */ +export interface RepositoryIsolationQueryResult { + readonly scopedRepositoryId: string; + readonly returnedEntities: readonly SnapshotEntityRecord[]; + /** + * Every repository id present across the loaded envelopes, in load order. + * + * Reported so a caller can tell *"the query looked across three envelopes and + * two of them were out of scope"* from *"the query saw nothing"*. An empty + * result is otherwise indistinguishable from a query that never ran + * (ADR-0016). + */ + readonly repositoriesConsidered: readonly string[]; + /** Envelopes whose repository id was not the scoped one. **Not** rejections. */ + readonly envelopesOutOfScope: number; +} + +/** + * Query several individually-valid single-repository envelopes, scoped to one + * repository id. + * + * **Nothing is rejected here.** An envelope for another repository is not an + * error; it simply contributes no entities. That is the whole of §6. + */ +export function queryEntitiesForRepository( + loadedEnvelopes: readonly StructurallyValidEnvelope[], + scopedRepositoryId: string, +): RepositoryIsolationQueryResult { + const repositoriesConsidered: string[] = []; + const returnedEntities: SnapshotEntityRecord[] = []; + let envelopesOutOfScope = 0; + + for (const validated of loadedEnvelopes) { + const envelope = envelopeOf(validated); + repositoriesConsidered.push(envelope.repository.id); + if (envelope.repository.id !== scopedRepositoryId) { + envelopesOutOfScope += 1; + continue; + } + returnedEntities.push(...envelope.entities); + } + + return { + scopedRepositoryId, + returnedEntities, + repositoriesConsidered, + envelopesOutOfScope, + }; +} diff --git a/packages/catalog-envelope/src/identity/staleness.ts b/packages/catalog-envelope/src/identity/staleness.ts new file mode 100644 index 00000000..8a0270ba --- /dev/null +++ b/packages/catalog-envelope/src/identity/staleness.ts @@ -0,0 +1,126 @@ +/** + * Staleness — **exact inequality of revision**, never a chronological or + * ordering comparison (`snapshot-envelope.md` §4; `data-model.md` §13; + * `spec.md` FR-047). + * + * ## Why there is no comparison operator anywhere in this file + * + * A commit SHA is an opaque identifier. It carries no ordering, and recovering + * one would require git-ancestry data that is explicitly out of scope for this + * feature. So "stale" here means exactly one thing: *the revision this envelope + * declares is not the revision the consumer was configured to expect.* It never + * means "older than", "behind", or "superseded by". + * + * The distinction matters because the wrong implementation is easy to write and + * looks right: a lexicographic `<` on two hex strings produces an ordering, that + * ordering is meaningless, and nothing in the output would say so. There is no + * `<`, `>`, `sort`, or date arithmetic in this module, and there must not be. + * + * ## Configuration is optional; the check is not a default + * + * A consumer *may* be configured with an expected-current revision. When it is + * not, no staleness verdict is available and this module says so explicitly + * rather than reporting `ok` — reporting `ok` for "not configured" would render + * an unchecked envelope identically to a checked one (ADR-0016's central + * failure shape). + * + * ## The expectation is keyed to a repository, and that is load-bearing + * + * `snapshot-envelope.md` §4 configures the expected-current revision **for a + * given repository ID**, and calls an envelope stale when it declares another + * revision *for that same repository ID*. A revision belonging to repository A + * therefore says nothing about an envelope describing repository B, and + * comparing them would be meaningless. + * + * This matters because `spec.md` FR-046 fixes the order — digest, then + * staleness, then repository identity — so staleness is evaluated **before** the + * identity mismatch has been named. Without the repository scoping, an envelope + * from a foreign repository would be refused as *stale* rather than as + * *misidentified*: the right verdict category for the wrong reason, and exactly + * the §5/§6 conflation the contract warns against. When the envelope is about a + * different repository, this module returns `not-applicable-different-repository` + * and leaves the verdict to the identity check that follows it. + */ + +import type { SnapshotEnvelope } from '../envelope-shape.ts'; +import { envelopeOf, type StructurallyValidEnvelope } from '../validate/index.ts'; + +export interface StalenessCheckResult { + readonly expectedRevision: string | undefined; + readonly declaredRevision: string; + /** + * `not-configured` and `not-applicable-different-repository` are both distinct + * from `ok`. The first means no expectation was supplied; the second means the + * expectation belongs to a different repository and so nothing comparable was + * available. Only `ok` means an expectation was supplied, was about this + * repository, and matched exactly. + */ + readonly outcome: 'ok' | 'stale-revision' | 'not-configured' | 'not-applicable-different-repository'; + readonly detail: string; + /** + * Recorded on the result so evidence generated from it cannot silently imply + * an ordering judgement was made. + */ + readonly comparison: typeof STALENESS_COMPARISON; +} + +export const STALENESS_COMPARISON = + 'exact string inequality of repository.revision, scoped to the repository the expectation was configured for; never a chronological, ancestry, or ordering comparison' as const; + +/** + * Compare an envelope's declared revision against a configured expectation. + * + * Takes a `StructurallyValidEnvelope` because `snapshot-envelope.md` §2 forbids + * any revision check before all five validation steps pass. + * + * @param expectedRevision the expected-current revision, if configured + * @param expectedRepositoryId the repository that expectation is *about*. When + * supplied and the envelope declares a different repository, no staleness + * verdict is available — see the note on repository scoping above. + */ +export function checkStaleness( + validated: StructurallyValidEnvelope, + expectedRevision?: string, + expectedRepositoryId?: string, +): StalenessCheckResult { + const envelope: SnapshotEnvelope = envelopeOf(validated); + const declaredRevision = envelope.repository.revision; + + if (expectedRevision === undefined) { + return { + expectedRevision: undefined, + declaredRevision, + outcome: 'not-configured', + detail: `no expected-current revision was configured, so revision ${declaredRevision} was not compared against anything`, + comparison: STALENESS_COMPARISON, + }; + } + + if (expectedRepositoryId !== undefined && envelope.repository.id !== expectedRepositoryId) { + return { + expectedRevision, + declaredRevision, + outcome: 'not-applicable-different-repository', + detail: `the expected-current revision ${expectedRevision} is configured for repository ${expectedRepositoryId}, but this envelope declares repository ${envelope.repository.id}, so no staleness verdict is available`, + comparison: STALENESS_COMPARISON, + }; + } + + if (declaredRevision !== expectedRevision) { + return { + expectedRevision, + declaredRevision, + outcome: 'stale-revision', + detail: `envelope declares revision ${declaredRevision}, which is not exactly equal to the configured expected-current revision ${expectedRevision}`, + comparison: STALENESS_COMPARISON, + }; + } + + return { + expectedRevision, + declaredRevision, + outcome: 'ok', + detail: `envelope revision ${declaredRevision} is exactly equal to the configured expected-current revision`, + comparison: STALENESS_COMPARISON, + }; +} diff --git a/packages/catalog-envelope/src/index.ts b/packages/catalog-envelope/src/index.ts index 33cd8cb0..c16dc0e1 100644 --- a/packages/catalog-envelope/src/index.ts +++ b/packages/catalog-envelope/src/index.ts @@ -22,14 +22,14 @@ * shape would change it on both sides at once, and this package's validation * would be comparing the generator against itself. * - * **What exists today.** Phase A of feature `010-catalog-backstage` creates this - * package's placement, its dependency boundary, and this entry point, and nothing - * else. The five ordered validation steps, digest recomputation, staleness - * evaluation, repository-identity handling, and snapshot derivation are - * requirements on a later phase, not behaviour this package has. + * **The ordering, which is the contract.** Five validation steps, then digest + * recomputation, then staleness, then repository identity — and only then + * derivation. {@link admitEnvelope} is the single entry that runs all of it, and + * {@link deriveCatalogSnapshot} refuses anything it did not admit. * * @see {@link ../README.md} * @see `specs/010-catalog-backstage/contracts/package-boundary.md` + * @see `specs/009-catalog-binding-viability/contracts/snapshot-envelope.md` */ /** @@ -40,3 +40,76 @@ * that coverage (ADR-0016 clause 3). */ export const PACKAGE_NAME = '@adrkit/catalog-envelope'; + +export { + ENTITY_RECORD_FIELDS, + ENVELOPE_SCHEMA_VERSION, + ENVELOPE_TOP_LEVEL_FIELDS, + FROZEN_CAPABILITIES, + FROZEN_GLOB_DIALECT, + RECOGNIZED_DIGEST_ALGORITHM, + RECOGNIZED_OWNERSHIP_STATES, + isRecognizedProvenance, + type EnvelopeCompleteness, + type EnvelopeGlobDialect, + type EnvelopeGlobOptions, + type EnvelopeRepository, + type EnvelopeSource, + type OwnershipState, + type SerializedEntityIdentity, + type SerializedSourceDocument, + type SnapshotEntityRecord, + type SnapshotEnvelope, +} from './envelope-shape.ts'; + +export { + REASON_STEP, + envelopeOf, + isStructurallyValidEnvelope, + validateEnvelope, + validateParsedEnvelope, + type EnvelopeExamination, + type EnvelopeRejectionReason, + type EnvelopeValidationRejected, + type EnvelopeValidationResult, + type EnvelopeValidationValid, + type StructurallyValidEnvelope, + type ValidateOptions, + type ValidationStep, +} from './validate/index.ts'; + +export { + DIGEST_GUARANTEE_SCOPE, + canonicalFormOf, + checkEnvelopeDigest, + recomputeEnvelopeDigest, + type DigestCheckResult, +} from './digest/index.ts'; + +export { + STALENESS_COMPARISON, + checkStaleness, + type StalenessCheckResult, +} from './identity/staleness.ts'; + +export { + checkRepositoryIdentity, + queryEntitiesForRepository, + type RepositoryIdentityCheckResult, + type RepositoryIsolationQueryResult, +} from './identity/repository.ts'; + +export { + EnvelopeDerivationRefusedError, + admitEnvelope, + admittedEnvelopeOf, + deriveCatalogSnapshot, + isAdmittedEnvelope, + type AdmissionAdmitted, + type AdmissionRefused, + type AdmissionResult, + type AdmissionStage, + type AdmitOptions, + type AdmittedEnvelope, + type DerivedCatalogSnapshot, +} from './snapshot/index.ts'; diff --git a/packages/catalog-envelope/src/snapshot/index.ts b/packages/catalog-envelope/src/snapshot/index.ts new file mode 100644 index 00000000..49750f0c --- /dev/null +++ b/packages/catalog-envelope/src/snapshot/index.ts @@ -0,0 +1,253 @@ +/** + * `CatalogSnapshot`-shaped derivation, reachable **only** after every check has + * passed (`spec.md` FR-049; `data-model.md` §15; ADR-0020 clause 7). + * + * ## The gate + * + * Derivation requires an `AdmittedEnvelope`, and the only way to obtain one is + * {@link admitEnvelope}, which runs — in this order, stopping at the first + * failure: + * + * 1. the five ordered validation steps (`snapshot-envelope.md` §2), + * 2. independent digest recomputation (§3), + * 3. staleness as exact revision inequality (§4), + * 4. repository identity (§5). + * + * The token is branded with a module-private symbol, so it cannot be forged by + * assembling an object literal. {@link deriveCatalogSnapshot} accepts `unknown` + * and **refuses** anything without that brand, at runtime, by throwing. An + * adapter's raw output cannot be handed to core directly and unvalidated under + * any composition arrangement, because there is no code path that accepts it. + * + * ## What derivation does not establish + * + * Admission proves the envelope is intact, intelligible, current, and about the + * repository that asked. It proves nothing about whether the ownership recorded + * in it is right. The derived snapshot inherits exactly that standing. + * + * ## The mapping is lossy by design + * + * `CatalogSnapshotEntity` is `{ id, refs?, paths? }` — it has **no** + * `ownershipState` field. So `explicit-empty` (an annotation that decoded to an + * empty array) and `annotation-absent` (no annotation at all) both map to an + * entity with an empty `paths`, and the distinction the envelope preserves is + * simply **not representable** in the core type. + * + * That distinction is therefore kept on the envelope side and is **not** + * smuggled into `CatalogSnapshot`. Changing `CatalogSnapshot` to carry it is out + * of scope (`spec.md` FR-004, FR-020). A caller that needs the ownership state + * must read it from the envelope, which {@link admittedEnvelopeOf} exposes. + */ + +import type { CatalogSnapshot, CatalogSnapshotEntity } from '@adrkit/core'; +import type { SnapshotEnvelope } from '../envelope-shape.ts'; +import { checkEnvelopeDigest, type DigestCheckResult } from '../digest/index.ts'; +import { + checkRepositoryIdentity, + type RepositoryIdentityCheckResult, +} from '../identity/repository.ts'; +import { checkStaleness, type StalenessCheckResult } from '../identity/staleness.ts'; +import { + envelopeOf, + validateEnvelope, + type EnvelopeValidationResult, + type StructurallyValidEnvelope, +} from '../validate/index.ts'; + +const ADMITTED: unique symbol = Symbol('adrkit.catalog-envelope.admitted'); + +/** An envelope that has passed all five steps, the digest, staleness, and identity. */ +export interface AdmittedEnvelope { + readonly [ADMITTED]: true; + readonly envelope: SnapshotEnvelope; + readonly digestCheck: DigestCheckResult; + readonly stalenessCheck: StalenessCheckResult; + readonly identityCheck: RepositoryIdentityCheckResult; +} + +export interface AdmitOptions { + /** Directory `sources[].path` entries are resolved against for step 4. */ + readonly sourceBaseDir: string; + /** When supplied, an envelope declaring a different repository is refused. */ + readonly expectedRepositoryId?: string; + /** + * When supplied, an envelope declaring any other revision **for the expected + * repository** is refused as stale. Scoped by `expectedRepositoryId` when that + * is also supplied, per `snapshot-envelope.md` §4 — see `identity/staleness.ts`. + */ + readonly expectedRevision?: string; +} + +/** Named stage at which admission stopped. */ +export type AdmissionStage = 'validation' | 'digest' | 'staleness' | 'repository-identity'; + +export interface AdmissionAdmitted { + readonly outcome: 'admitted'; + readonly admitted: AdmittedEnvelope; + readonly validation: EnvelopeValidationResult; + readonly refusedAt: undefined; + readonly reason: undefined; + readonly detail: undefined; +} + +export interface AdmissionRefused { + readonly outcome: 'refused'; + readonly admitted: undefined; + readonly validation: EnvelopeValidationResult; + readonly refusedAt: AdmissionStage; + readonly reason: string; + readonly detail: string; +} + +export type AdmissionResult = AdmissionAdmitted | AdmissionRefused; + +/** + * Run the whole ordered pipeline over raw envelope text. + * + * Nothing is derived here — this only decides whether derivation is permitted. + */ +export function admitEnvelope(text: string, options: AdmitOptions): AdmissionResult { + const validation = validateEnvelope(text, { sourceBaseDir: options.sourceBaseDir }); + if (validation.outcome === 'rejected') { + return { + outcome: 'refused', + admitted: undefined, + validation, + refusedAt: 'validation', + reason: validation.reason, + detail: `step ${validation.failedStep}: ${validation.detail}`, + }; + } + + const validated: StructurallyValidEnvelope = validation.validated; + + const digestCheck = checkEnvelopeDigest(validated); + if (digestCheck.outcome === 'digest-mismatch') { + return { + outcome: 'refused', + admitted: undefined, + validation, + refusedAt: 'digest', + reason: 'digest-mismatch', + detail: `envelope declares digest ${digestCheck.declaredDigest} but its canonical form hashes to ${digestCheck.recomputedDigest}`, + }; + } + + const stalenessCheck = checkStaleness(validated, options.expectedRevision, options.expectedRepositoryId); + if (stalenessCheck.outcome === 'stale-revision') { + return { + outcome: 'refused', + admitted: undefined, + validation, + refusedAt: 'staleness', + reason: 'stale-revision', + detail: stalenessCheck.detail, + }; + } + + const identityCheck = checkRepositoryIdentity(validated, options.expectedRepositoryId); + if (identityCheck.outcome === 'repository-identity-mismatch') { + return { + outcome: 'refused', + admitted: undefined, + validation, + refusedAt: 'repository-identity', + reason: 'repository-identity-mismatch', + detail: identityCheck.detail, + }; + } + + return { + outcome: 'admitted', + admitted: { + [ADMITTED]: true, + envelope: envelopeOf(validated), + digestCheck, + stalenessCheck, + identityCheck, + }, + validation, + refusedAt: undefined, + reason: undefined, + detail: undefined, + }; +} + +/** + * Thrown when derivation is attempted on anything that has not been admitted. + * + * A thrown refusal rather than a returned one, deliberately: a caller can + * ignore a returned rejection value and carry on to read `derivedPaths` anyway, + * and FR-046 does not permit that. + */ +export class EnvelopeDerivationRefusedError extends Error { + readonly reason = 'derivation-refused-envelope-not-admitted' as const; + + constructor(what: string) { + super( + `derivation refused: ${what} has not passed the five validation steps, digest recomputation, staleness, and repository-identity checks`, + ); + this.name = 'EnvelopeDerivationRefusedError'; + } +} + +/** True only for a token {@link admitEnvelope} minted. */ +export function isAdmittedEnvelope(value: unknown): value is AdmittedEnvelope { + return ( + typeof value === 'object' && value !== null && (value as Record)[ADMITTED] === true + ); +} + +/** The envelope behind an admission token, for callers that need the lossy fields. */ +export function admittedEnvelopeOf(admitted: AdmittedEnvelope): SnapshotEnvelope { + return admitted.envelope; +} + +/** `data-model.md` §15. */ +export interface DerivedCatalogSnapshot { + /** The **existing** `@adrkit/core` type, unmodified. */ + readonly snapshot: CatalogSnapshot; + readonly derivedFrom: { + readonly repositoryId: string; + readonly revision: string; + readonly envelopeDigest: string; + }; +} + +/** + * Derive a `CatalogSnapshot`-shaped artifact. + * + * Accepts `unknown` on purpose. The type system already prevents an unadmitted + * value from reaching here in well-typed code; accepting `unknown` and checking + * the brand at runtime means the refusal also holds for a caller that reached + * this function through a cast, through JavaScript, or through a future edit + * that widened a type somewhere upstream. + * + * @throws {EnvelopeDerivationRefusedError} when the value carries no admission brand. + */ +export function deriveCatalogSnapshot(admitted: unknown): DerivedCatalogSnapshot { + if (!isAdmittedEnvelope(admitted)) { + throw new EnvelopeDerivationRefusedError( + admitted === null || admitted === undefined ? String(admitted) : `a ${typeof admitted} value`, + ); + } + + const envelope = admitted.envelope; + const entities: CatalogSnapshotEntity[] = envelope.entities.map((record) => ({ + id: record.identity.canonicalId, + refs: [...record.identity.allRefs], + // Always emitted, empty array included, so the output is deterministic. + // `explicit-empty` and `annotation-absent` are indistinguishable here by + // design — the discriminator stays on the envelope. + paths: [...record.derivedPaths], + })); + + return { + snapshot: { entities }, + derivedFrom: { + repositoryId: envelope.repository.id, + revision: envelope.repository.revision, + envelopeDigest: envelope.digest, + }, + }; +} diff --git a/packages/catalog-envelope/src/validate/index.ts b/packages/catalog-envelope/src/validate/index.ts new file mode 100644 index 00000000..00183203 --- /dev/null +++ b/packages/catalog-envelope/src/validate/index.ts @@ -0,0 +1,477 @@ +/** + * The **five ordered consumer validation steps** + * (`specs/009-catalog-binding-viability/contracts/snapshot-envelope.md` §2, + * carried forward unchanged by feature 010; `spec.md` FR-045; + * `data-model.md` §11). + * + * The order is the contract, not an implementation detail. Each step rejects + * with **its own** reason, and no later check — digest, revision, or repository + * identity — is attempted until every one of the five has passed. Getting the + * order wrong would let an envelope be rejected for the wrong reason, which is + * indistinguishable in a log from being rejected for the right one. + * + * **What a pass here does and does not establish.** All five passing means the + * envelope is *structurally intelligible*. It says nothing about whether the + * ownership it records is right. That question is not answerable by this + * package at all. + */ + +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + ENTITY_RECORD_FIELDS, + ENVELOPE_SCHEMA_VERSION, + ENVELOPE_TOP_LEVEL_FIELDS, + FROZEN_CAPABILITIES, + FROZEN_GLOB_DIALECT, + RECOGNIZED_DIGEST_ALGORITHM, + RECOGNIZED_OWNERSHIP_STATES, + isRecognizedProvenance, + type SnapshotEnvelope, +} from '../envelope-shape.ts'; + +/** + * The five reasons, one per step (`data-model.md` §11). This union is closed: + * there is no sixth reason, and no step emits a reason belonging to another. + */ +export type EnvelopeRejectionReason = + | 'invalid-json' + | 'missing-or-wrong-required-field' + | 'unrecognized-schema-or-dialect-or-capability' + | 'missing-source-digest' + | 'identity-only-true'; + +export type ValidationStep = 1 | 2 | 3 | 4 | 5; + +/** The step each reason belongs to. Frozen 1:1 by `snapshot-envelope.md` §2. */ +export const REASON_STEP: Readonly> = { + 'invalid-json': 1, + 'missing-or-wrong-required-field': 2, + 'unrecognized-schema-or-dialect-or-capability': 3, + 'missing-source-digest': 4, + 'identity-only-true': 5, +}; + +/** + * What the validator actually looked at, reported alongside what it concluded. + * + * This is ADR-0016's complementary half: "report what was examined, not only + * what was concluded", so that a reader can tell *looked and found nothing* + * from *could not look*. Without it, a validator that silently inspected zero + * entity records would render identically to one that inspected all of them and + * found them sound. + */ +export interface EnvelopeExamination { + /** Entity records step 2 inspected, including the one it rejected on. */ + readonly entityRecordsInspected: number; + /** Source paths step 4 actually opened and hashed, in declaration order. */ + readonly sourcesVerified: readonly string[]; + /** The highest step reached, whether or not it passed. */ + readonly stepsReached: ValidationStep; +} + +const VALIDATED: unique symbol = Symbol('adrkit.catalog-envelope.structurally-valid'); + +/** + * An envelope that has passed **all five** steps. + * + * The brand is a module-private symbol, so this token cannot be forged by a + * caller assembling an object literal. It is what makes "no derived value is + * read before validation" a property of the type system and of the runtime, + * rather than a convention a later edit could quietly drop. + */ +export interface StructurallyValidEnvelope { + readonly [VALIDATED]: true; + readonly envelope: SnapshotEnvelope; +} + +export interface EnvelopeValidationValid { + readonly outcome: 'valid'; + readonly failedStep: undefined; + readonly reason: undefined; + readonly detail: undefined; + readonly examined: EnvelopeExamination; + readonly validated: StructurallyValidEnvelope; +} + +export interface EnvelopeValidationRejected { + readonly outcome: 'rejected'; + readonly failedStep: ValidationStep; + readonly reason: EnvelopeRejectionReason; + /** + * Human-readable specifics. Never load-bearing: the reason and the step are + * what callers branch on. This exists so a rejection names *which* field or + * *which* source failed instead of only naming its category. + */ + readonly detail: string; + readonly examined: EnvelopeExamination; + readonly validated: undefined; +} + +export type EnvelopeValidationResult = EnvelopeValidationValid | EnvelopeValidationRejected; + +export interface ValidateOptions { + /** + * Directory that `sources[].path` entries are resolved against for step 4. + * + * Required, and deliberately not defaulted to the process's working + * directory: a step-4 digest check silently reading the wrong tree would pass + * or fail for reasons unrelated to the envelope. + */ + readonly sourceBaseDir: string; +} + +/** Unwraps a validated envelope. The only way to reach the payload. */ +export function envelopeOf(validated: StructurallyValidEnvelope): SnapshotEnvelope { + return validated.envelope; +} + +/** True only for a token this module minted. */ +export function isStructurallyValidEnvelope(value: unknown): value is StructurallyValidEnvelope { + return ( + typeof value === 'object' && value !== null && (value as Record)[VALIDATED] === true + ); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((element) => typeof element === 'string'); +} + +function reject( + reason: EnvelopeRejectionReason, + detail: string, + examined: EnvelopeExamination, +): EnvelopeValidationRejected { + return { + outcome: 'rejected', + failedStep: REASON_STEP[reason], + reason, + detail, + examined, + validated: undefined, + }; +} + +function examination( + stepsReached: ValidationStep, + entityRecordsInspected: number, + sourcesVerified: readonly string[], +): EnvelopeExamination { + return { entityRecordsInspected, sourcesVerified, stepsReached }; +} + +/** + * Step 2's shape check for one entity record. + * + * Returns a detail string on failure, `undefined` on success. + * + * **The single `derivedPaths` property read in the whole validator lives here**, + * on the line marked below, and it is a *type inspection*: the array is read + * once to confirm it is an array of strings, and its contents are never + * consumed, compared, matched, or returned. Every other reference to + * `derivedPaths` in this package is in the type declaration + * (`envelope-shape.ts`) or behind the admission gate (`snapshot/index.ts`). + * `test/no-early-read.test.ts` counts these reads and enforces that discipline. + */ +function checkEntityRecord(record: unknown, index: number): string | undefined { + if (!isPlainObject(record)) return `entities[${index}] is not an object`; + + const extra = Object.keys(record).filter( + (key) => !(ENTITY_RECORD_FIELDS as readonly string[]).includes(key), + ); + if (extra.length > 0) { + return `entities[${index}] carries unrecognized field(s) ${extra.join(', ')}; exactly ${ENTITY_RECORD_FIELDS.length} fields are defined`; + } + + const identity = record['identity']; + if (!isPlainObject(identity)) return `entities[${index}].identity is not an object`; + if (typeof identity['canonicalId'] !== 'string') { + return `entities[${index}].identity.canonicalId is not a string`; + } + if (!isStringArray(identity['allRefs'])) return `entities[${index}].identity.allRefs is not a string array`; + if (identity['allRefs'].length === 0) return `entities[${index}].identity.allRefs is empty`; + + const ownershipState = record['ownershipState']; + if ( + typeof ownershipState !== 'string' || + !(RECOGNIZED_OWNERSHIP_STATES as readonly string[]).includes(ownershipState) + ) { + return `entities[${index}].ownershipState is not one of ${RECOGNIZED_OWNERSHIP_STATES.join(' | ')}`; + } + + const derivedPaths = record['derivedPaths']; // STEP-2 TYPE INSPECTION — the only read + if (!isStringArray(derivedPaths)) return `entities[${index}].derivedPaths is not a string array`; + + const sourceDocument = record['sourceDocument']; + if (!isPlainObject(sourceDocument)) return `entities[${index}].sourceDocument is not an object`; + if (typeof sourceDocument['sourcePath'] !== 'string') { + return `entities[${index}].sourceDocument.sourcePath is not a string`; + } + const documentIndexInFile = sourceDocument['documentIndexInFile']; + if ( + typeof documentIndexInFile !== 'number' || + !Number.isInteger(documentIndexInFile) || + documentIndexInFile < 0 + ) { + return `entities[${index}].sourceDocument.documentIndexInFile is not a non-negative integer`; + } + + if (!isRecognizedProvenance(record['provenance'])) { + return `entities[${index}].provenance is not a recognized (non-empty string) provenance`; + } + + return undefined; +} + +/** Step 2's shape check for one `sources[]` entry. Digest presence is step 4's job. */ +function checkSourceEntry(entry: unknown, index: number): string | undefined { + if (!isPlainObject(entry)) return `sources[${index}] is not an object`; + if (typeof entry['path'] !== 'string') return `sources[${index}].path is not a string`; + if (typeof entry['digestAlgorithm'] !== 'string') { + return `sources[${index}].digestAlgorithm is not a string`; + } + const digest = entry['digest']; + if (digest !== undefined && typeof digest !== 'string') { + return `sources[${index}].digest is present but not a string`; + } + const extra = Object.keys(entry).filter((key) => !['path', 'digestAlgorithm', 'digest'].includes(key)); + if (extra.length > 0) return `sources[${index}] carries unrecognized field(s) ${extra.join(', ')}`; + return undefined; +} + +/** + * Steps 2 through 5, against an already-parsed value. + * + * Exported as a seam so a test can drive validation with an **instrumented** + * object and count property reads. Step 1 is skipped here by construction: a + * value that is already a JavaScript value has, trivially, parsed. + */ +export function validateParsedEnvelope( + value: unknown, + options: ValidateOptions, +): EnvelopeValidationResult { + // ---- Step 2: complete shape, correct JSON type, at every nesting level ---- + let inspected = 0; + const at2 = (): EnvelopeExamination => examination(2, inspected, []); + + if (!isPlainObject(value)) { + return reject('missing-or-wrong-required-field', 'envelope is not a JSON object', at2()); + } + + const missing = ENVELOPE_TOP_LEVEL_FIELDS.filter((field) => !(field in value)); + if (missing.length > 0) { + return reject('missing-or-wrong-required-field', `missing top-level field(s) ${missing.join(', ')}`, at2()); + } + const extraTop = Object.keys(value).filter( + (key) => !(ENVELOPE_TOP_LEVEL_FIELDS as readonly string[]).includes(key), + ); + if (extraTop.length > 0) { + return reject( + 'missing-or-wrong-required-field', + `unrecognized top-level field(s) ${extraTop.join(', ')}; exactly ${ENVELOPE_TOP_LEVEL_FIELDS.length} fields are defined`, + at2(), + ); + } + + if (typeof value['schemaVersion'] !== 'string') { + return reject('missing-or-wrong-required-field', 'schemaVersion is not a string', at2()); + } + if (typeof value['generatorVersion'] !== 'string') { + return reject('missing-or-wrong-required-field', 'generatorVersion is not a string', at2()); + } + if (typeof value['digest'] !== 'string') { + return reject('missing-or-wrong-required-field', 'digest is not a string', at2()); + } + + const repository = value['repository']; + if (!isPlainObject(repository)) { + return reject('missing-or-wrong-required-field', 'repository is not an object', at2()); + } + if (typeof repository['id'] !== 'string') { + return reject('missing-or-wrong-required-field', 'repository.id is not a string', at2()); + } + if (typeof repository['revision'] !== 'string') { + return reject('missing-or-wrong-required-field', 'repository.revision is not a string', at2()); + } + + const globDialect = value['globDialect']; + if (!isPlainObject(globDialect)) { + return reject('missing-or-wrong-required-field', 'globDialect is not an object', at2()); + } + if (typeof globDialect['engine'] !== 'string' || typeof globDialect['version'] !== 'string') { + return reject( + 'missing-or-wrong-required-field', + 'globDialect.engine or globDialect.version is not a string', + at2(), + ); + } + const globOptions = globDialect['options']; + if (!isPlainObject(globOptions)) { + return reject('missing-or-wrong-required-field', 'globDialect.options is not an object', at2()); + } + for (const flag of ['dot', 'nocase', 'nonegate'] as const) { + if (typeof globOptions[flag] !== 'boolean') { + return reject('missing-or-wrong-required-field', `globDialect.options.${flag} is not a boolean`, at2()); + } + } + + if (!isStringArray(value['capabilities'])) { + return reject('missing-or-wrong-required-field', 'capabilities is not a string array', at2()); + } + + const completeness = value['completeness']; + if (!isPlainObject(completeness)) { + return reject('missing-or-wrong-required-field', 'completeness is not an object', at2()); + } + for (const flag of ['wholeCatalog', 'identityOnly'] as const) { + if (typeof completeness[flag] !== 'boolean') { + return reject('missing-or-wrong-required-field', `completeness.${flag} is not a boolean`, at2()); + } + } + + const sources = value['sources']; + if (!Array.isArray(sources)) { + return reject('missing-or-wrong-required-field', 'sources is not an array', at2()); + } + for (let index = 0; index < sources.length; index += 1) { + const detail = checkSourceEntry(sources[index], index); + if (detail !== undefined) return reject('missing-or-wrong-required-field', detail, at2()); + } + + const entities = value['entities']; + if (!Array.isArray(entities)) { + return reject('missing-or-wrong-required-field', 'entities is not an array', at2()); + } + for (let index = 0; index < entities.length; index += 1) { + inspected += 1; + const detail = checkEntityRecord(entities[index], index); + if (detail !== undefined) return reject('missing-or-wrong-required-field', detail, at2()); + } + + // ---- Step 3: the frozen matcher contract, by exact value ---- + const at3 = (): EnvelopeExamination => examination(3, inspected, []); + + if (value['schemaVersion'] !== ENVELOPE_SCHEMA_VERSION) { + return reject( + 'unrecognized-schema-or-dialect-or-capability', + `schemaVersion is ${JSON.stringify(value['schemaVersion'])}, expected ${JSON.stringify(ENVELOPE_SCHEMA_VERSION)}`, + at3(), + ); + } + if (globDialect['engine'] !== FROZEN_GLOB_DIALECT.engine) { + return reject( + 'unrecognized-schema-or-dialect-or-capability', + `globDialect.engine is ${JSON.stringify(globDialect['engine'])}, expected ${JSON.stringify(FROZEN_GLOB_DIALECT.engine)}`, + at3(), + ); + } + if (globDialect['version'] !== FROZEN_GLOB_DIALECT.version) { + return reject( + 'unrecognized-schema-or-dialect-or-capability', + `globDialect.version is ${JSON.stringify(globDialect['version'])}, expected ${JSON.stringify(FROZEN_GLOB_DIALECT.version)}`, + at3(), + ); + } + for (const flag of ['dot', 'nocase', 'nonegate'] as const) { + if (globOptions[flag] !== FROZEN_GLOB_DIALECT.options[flag]) { + return reject( + 'unrecognized-schema-or-dialect-or-capability', + `globDialect.options.${flag} is ${String(globOptions[flag])}, expected ${String(FROZEN_GLOB_DIALECT.options[flag])}`, + at3(), + ); + } + } + + const capabilities = value['capabilities']; + const capabilitiesMatch = + capabilities.length === FROZEN_CAPABILITIES.length && + FROZEN_CAPABILITIES.every((expected, index) => capabilities[index] === expected); + if (!capabilitiesMatch) { + return reject( + 'unrecognized-schema-or-dialect-or-capability', + `capabilities is ${JSON.stringify(capabilities)}, expected exactly ${JSON.stringify(FROZEN_CAPABILITIES)}`, + at3(), + ); + } + + // ---- Step 4: every source digest present, correctly typed, matching bytes ---- + const verified: string[] = []; + const at4 = (): EnvelopeExamination => examination(4, inspected, [...verified]); + + for (let index = 0; index < sources.length; index += 1) { + const entry = sources[index] as Record; + const path = entry['path'] as string; + const declared = entry['digest']; + if (typeof declared !== 'string') { + return reject('missing-source-digest', `sources[${index}] (${path}) declares no digest`, at4()); + } + const algorithm = entry['digestAlgorithm'] as string; + if (algorithm !== RECOGNIZED_DIGEST_ALGORITHM) { + return reject( + 'missing-source-digest', + `sources[${index}] (${path}) declares digestAlgorithm ${JSON.stringify(algorithm)}, which this consumer cannot verify; expected ${JSON.stringify(RECOGNIZED_DIGEST_ALGORITHM)}`, + at4(), + ); + } + let bytes: Uint8Array; + try { + bytes = readFileSync(join(options.sourceBaseDir, path)); + } catch (error) { + return reject( + 'missing-source-digest', + `sources[${index}] (${path}) could not be read from ${options.sourceBaseDir}, so its digest cannot be matched: ${String(error)}`, + at4(), + ); + } + const actual = createHash('sha256').update(bytes).digest('hex'); + verified.push(path); + if (actual !== declared) { + return reject( + 'missing-source-digest', + `sources[${index}] (${path}) declares digest ${declared} but its bytes hash to ${actual}`, + at4(), + ); + } + } + + // ---- Step 5: completeness.identityOnly === false ---- + const at5 = (): EnvelopeExamination => examination(5, inspected, [...verified]); + + if (completeness['identityOnly'] !== false) { + return reject( + 'identity-only-true', + 'completeness.identityOnly is true, so this envelope is partial/identity-only and unusable for path-ownership matching', + at5(), + ); + } + + return { + outcome: 'valid', + failedStep: undefined, + reason: undefined, + detail: undefined, + examined: at5(), + validated: { [VALIDATED]: true, envelope: value as unknown as SnapshotEnvelope }, + }; +} + +/** + * The full five-step validation, from raw envelope text. + * + * Step 1 is here and nowhere else: an envelope that does not parse is rejected + * before any structural claim is made about it. + */ +export function validateEnvelope(text: string, options: ValidateOptions): EnvelopeValidationResult { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + return reject('invalid-json', `envelope does not parse as JSON: ${String(error)}`, examination(1, 0, [])); + } + return validateParsedEnvelope(parsed, options); +} diff --git a/packages/catalog-envelope/test/derive.test.ts b/packages/catalog-envelope/test/derive.test.ts new file mode 100644 index 00000000..5665ab13 --- /dev/null +++ b/packages/catalog-envelope/test/derive.test.ts @@ -0,0 +1,170 @@ +/** + * `CatalogSnapshot`-shaped derivation, gated behind every check + * (`spec.md` FR-049; `data-model.md` §15; ADR-0020 clause 7; T034). + * + * The gate is asserted from both sides: the happy path, where derivation + * succeeds only after admission; and every refusal path, where derivation is + * unreachable. The refusal side lives in `no-early-read.test.ts`; this file + * covers what derivation *produces* once it is permitted, and the two + * properties of that output that are easy to get wrong — the lossy mapping, and + * the standing of the result. + */ + +import { describe, expect, test } from 'bun:test'; +import { + admitEnvelope, + admittedEnvelopeOf, + deriveCatalogSnapshot, + type AdmissionStage, +} from '../src/index.ts'; +import { + ADMIT_OPTIONS_A, + REPOSITORY_A, + REVISION_A, + SOURCE_BASE_DIR, + fixtureText, +} from './helpers.ts'; + +function admitValid() { + const result = admitEnvelope(fixtureText('valid.json'), ADMIT_OPTIONS_A); + if (result.outcome !== 'admitted') { + throw new Error(`valid.json was refused at ${result.refusedAt}: ${result.reason} — ${result.detail}`); + } + return result.admitted; +} + +describe('admission runs every check in order before permitting derivation', () => { + test('the valid fixture is admitted with all four verdicts recorded', () => { + const admitted = admitValid(); + + expect(admitted.digestCheck.outcome).toBe('match'); + expect(admitted.stalenessCheck.outcome).toBe('ok'); + expect(admitted.identityCheck.outcome).toBe('ok'); + // Not merely "did not fail": the specific values that were compared. + expect(admitted.stalenessCheck.expectedRevision).toBe(REVISION_A); + expect(admitted.identityCheck.expectedRepositoryId).toBe(REPOSITORY_A); + }); + + test('the refusal stage is named for every fixture that cannot be admitted', () => { + const expected: readonly (readonly [string, AdmissionStage, string])[] = [ + ['malformed-invalid-json.json', 'validation', 'invalid-json'], + ['malformed-missing-or-wrong-field.json', 'validation', 'missing-or-wrong-required-field'], + ['malformed-unrecognized.json', 'validation', 'unrecognized-schema-or-dialect-or-capability'], + ['malformed-missing-source-digest.json', 'validation', 'missing-source-digest'], + ['malformed-identity-only.json', 'validation', 'identity-only-true'], + ['tampered.json', 'digest', 'digest-mismatch'], + ['stale.json', 'staleness', 'stale-revision'], + ['wrong-repository.json', 'repository-identity', 'repository-identity-mismatch'], + ]; + + const observed = expected.map(([fixture]) => { + const result = admitEnvelope(fixtureText(fixture), ADMIT_OPTIONS_A); + if (result.outcome !== 'refused') throw new Error(`${fixture} was admitted`); + return [fixture, result.refusedAt, result.reason] as const; + }); + + expect(observed).toEqual([...expected]); + }); + + test('the all-annotation-absent contrast case is admitted, not refused', () => { + const result = admitEnvelope(fixtureText('all-annotation-absent.json'), ADMIT_OPTIONS_A); + expect(result.outcome).toBe('admitted'); + }); + + test('admission without a configured revision or repository still runs digest', () => { + const result = admitEnvelope(fixtureText('tampered.json'), { sourceBaseDir: SOURCE_BASE_DIR }); + expect(result.outcome).toBe('refused'); + if (result.outcome !== 'refused') return; + expect(result.refusedAt).toBe('digest'); + }); +}); + +describe('derivation output', () => { + test('every entity maps to a CatalogSnapshotEntity of id, refs and paths', () => { + const derived = deriveCatalogSnapshot(admitValid()); + + expect(derived.snapshot.entities).toEqual([ + { + id: 'component:default/payments', + refs: ['component:default/payments'], + paths: ['apis/payments/**', 'packages/payments/**'], + }, + { id: 'component:default/ledger', refs: ['component:default/ledger'], paths: [] }, + { id: 'component:default/gateway', refs: ['component:default/gateway'], paths: [] }, + ]); + }); + + test('the derived snapshot carries only the three core fields, never envelope fields', () => { + // `spec.md` FR-005: the envelope is a separate artifact and is never added as + // a field on `CatalogSnapshot` or `CatalogSnapshotEntity`. + const derived = deriveCatalogSnapshot(admitValid()); + + expect(Object.keys(derived.snapshot).sort()).toEqual(['entities']); + for (const entity of derived.snapshot.entities) { + expect(Object.keys(entity).sort()).toEqual(['id', 'paths', 'refs']); + } + }); + + test('provenance for the derivation is recorded outside the snapshot', () => { + const derived = deriveCatalogSnapshot(admitValid()); + + expect(derived.derivedFrom).toEqual({ + repositoryId: REPOSITORY_A, + revision: REVISION_A, + envelopeDigest: '08b544b48fb8c3f1672c249623ad7bffb3b025cd2a8cabea208d98800e279df2', + }); + }); + + test('derivation is deterministic', () => { + const first = JSON.stringify(deriveCatalogSnapshot(admitValid())); + const second = JSON.stringify(deriveCatalogSnapshot(admitValid())); + expect(first).toBe(second); + }); + + test('the snapshot does not alias the envelope arrays', () => { + const admitted = admitValid(); + const derived = deriveCatalogSnapshot(admitted); + const paths = derived.snapshot.entities[0]?.paths as string[]; + paths.push('mutated/**'); + + expect(admittedEnvelopeOf(admitted).entities[0]?.derivedPaths).toEqual([ + 'apis/payments/**', + 'packages/payments/**', + ]); + }); +}); + +describe('the mapping is lossy by design', () => { + test('explicit-empty and annotation-absent both map to an empty paths array', () => { + const admitted = admitValid(); + const envelope = admittedEnvelopeOf(admitted); + const derived = deriveCatalogSnapshot(admitted); + + // The distinction exists on the envelope... + expect(envelope.entities.map((entity) => entity.ownershipState)).toEqual([ + 'explicit-paths', + 'explicit-empty', + 'annotation-absent', + ]); + + // ...and is simply not representable in `CatalogSnapshotEntity`, which has + // no `ownershipState` field. It is kept on the envelope side rather than + // smuggled into the core type; changing `CatalogSnapshot` to carry it is out + // of scope (`spec.md` FR-004, FR-020). + const ledger = derived.snapshot.entities[1]; + const gateway = derived.snapshot.entities[2]; + expect(ledger?.paths).toEqual([]); + expect(gateway?.paths).toEqual([]); + expect(ledger).not.toHaveProperty('ownershipState'); + expect(gateway).not.toHaveProperty('ownershipState'); + }); + + test('an all-annotation-absent envelope derives a snapshot with no paths anywhere', () => { + const result = admitEnvelope(fixtureText('all-annotation-absent.json'), ADMIT_OPTIONS_A); + if (result.outcome !== 'admitted') throw new Error('contrast fixture was refused'); + const derived = deriveCatalogSnapshot(result.admitted); + + expect(derived.snapshot.entities).toHaveLength(3); + for (const entity of derived.snapshot.entities) expect(entity.paths).toEqual([]); + }); +}); diff --git a/packages/catalog-envelope/test/digest.test.ts b/packages/catalog-envelope/test/digest.test.ts new file mode 100644 index 00000000..cb542f11 --- /dev/null +++ b/packages/catalog-envelope/test/digest.test.ts @@ -0,0 +1,184 @@ +/** + * Independent digest recomputation (`snapshot-envelope.md` §3; + * `data-model.md` §12; `spec.md` FR-040, FR-041; T031). + * + * **Scope, before any assertion below.** A digest match proves + * accidental-corruption and naive-mutation detection only. It does not resist an + * adversary who mutates content and recomputes the same digest. And separately: + * a populated, digest-verified envelope proves **integrity, not correctness** — + * a semantically wrong envelope can carry a perfectly valid self-digest. Nothing + * in this file establishes that any `derivedPaths` value is *right*, and nothing + * here may be cited as if it did. + */ + +import { describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { canonicalStringify } from '@adrkit/core'; +import { + DIGEST_GUARANTEE_SCOPE, + admitEnvelope, + canonicalFormOf, + checkEnvelopeDigest, + recomputeEnvelopeDigest, + validateEnvelope, +} from '../src/index.ts'; +import { ADMIT_OPTIONS_A, VALIDATE_OPTIONS, fixtureText, fixtureValue } from './helpers.ts'; + +function validatedFixture(name: string) { + const result = validateEnvelope(fixtureText(name), VALIDATE_OPTIONS); + if (result.outcome !== 'valid') { + throw new Error(`${name} did not pass the five steps: ${result.reason} — ${result.detail}`); + } + return result.validated; +} + +describe('canonicalization', () => { + test('the digest field itself is excluded and every other field is included', () => { + const envelope = fixtureValue('valid.json'); + const canonical = canonicalFormOf(envelope as never); + + expect(canonical).not.toContain('"digest":"08b544b4'); + for (const field of [ + 'schemaVersion', + 'repository', + 'generatorVersion', + 'globDialect', + 'capabilities', + 'completeness', + 'sources', + 'entities', + ]) { + expect(canonical).toContain(`"${field}":`); + } + // `schemaVersion` is included — §3 step 1 says so explicitly, and omitting + // it is the easy mistake because it looks like metadata about the envelope + // rather than content of it. + expect(canonical.startsWith('{"capabilities":["pathOwnership"],')).toBe(true); + }); + + test('object keys are sorted at every nesting level, arrays are not', () => { + const envelope = fixtureValue('valid.json'); + const canonical = canonicalFormOf(envelope as never); + + // Top level, sorted: capabilities < completeness < entities < generatorVersion < ... + expect(canonical.indexOf('"capabilities"')).toBeLessThan(canonical.indexOf('"completeness"')); + expect(canonical.indexOf('"completeness"')).toBeLessThan(canonical.indexOf('"entities"')); + // Nested, sorted: globDialect { engine, options, version } + expect(canonical.indexOf('"engine"')).toBeLessThan(canonical.indexOf('"options"')); + expect(canonical.indexOf('"options":{"dot"')).toBeGreaterThan(-1); + // Arrays keep declaration order — the two payments patterns are not re-sorted + // relative to each other by the canonicalizer. + expect(canonical).toContain('["apis/payments/**","packages/payments/**"]'); + }); + + test('the digest is 64 lowercase hex over the UTF-8 bytes of the canonical form', () => { + const envelope = fixtureValue('valid.json'); + const recomputed = recomputeEnvelopeDigest(envelope as never); + + expect(recomputed).toMatch(/^[0-9a-f]{64}$/); + + // Recomputed the long way, independently of the module under test, so this + // asserts a specific observed value rather than that the function agrees + // with itself. + const { digest: _excluded, ...rest } = envelope as Record; + const expected = createHash('sha256').update(canonicalStringify(rest), 'utf8').digest('hex'); + expect(recomputed).toBe(expected); + expect(envelope['digest']).toBe(recomputed); + }); + + test('reordering the keys of an envelope does not change its digest', () => { + const envelope = fixtureValue('valid.json'); + const shuffled: Record = {}; + for (const key of Object.keys(envelope).reverse()) shuffled[key] = envelope[key]; + + expect(recomputeEnvelopeDigest(shuffled as never)).toBe(recomputeEnvelopeDigest(envelope as never)); + }); + + test('recomputation is stable across repeated runs', () => { + const envelope = fixtureValue('valid.json'); + const runs = new Set([0, 1, 2, 3, 4].map(() => recomputeEnvelopeDigest(envelope as never))); + expect(runs.size).toBe(1); + }); +}); + +describe('digest verification', () => { + test('the valid fixture matches', () => { + const result = checkEnvelopeDigest(validatedFixture('valid.json')); + + expect(result.outcome).toBe('match'); + expect(result.declaredDigest).toBe(result.recomputedDigest); + expect(result.declaredDigest).toBe('08b544b48fb8c3f1672c249623ad7bffb3b025cd2a8cabea208d98800e279df2'); + }); + + test('the tampered fixture is rejected, and the mismatch is named', () => { + // `tampered.json` gained a third element in `entities[0].derivedPaths` after + // the digest was computed. It passes all five validation steps — the payload + // is structurally perfect — so the only thing that can catch it is the + // recomputation. + const validation = validateEnvelope(fixtureText('tampered.json'), VALIDATE_OPTIONS); + expect(validation.outcome).toBe('valid'); + if (validation.outcome !== 'valid') return; + + const result = checkEnvelopeDigest(validation.validated); + expect(result.outcome).toBe('digest-mismatch'); + expect(result.declaredDigest).not.toBe(result.recomputedDigest); + expect(result.declaredDigest).toMatch(/^[0-9a-f]{64}$/); + expect(result.recomputedDigest).toMatch(/^[0-9a-f]{64}$/); + }); + + test('admission refuses the tampered fixture at the digest stage, not earlier', () => { + const result = admitEnvelope(fixtureText('tampered.json'), ADMIT_OPTIONS_A); + + expect(result.outcome).toBe('refused'); + if (result.outcome !== 'refused') return; + expect(result.refusedAt).toBe('digest'); + expect(result.reason).toBe('digest-mismatch'); + expect(result.detail).toContain('but its canonical form hashes to'); + // It passed all five steps first — the rejection is attributable to the + // digest and to nothing else. + expect(result.validation.outcome).toBe('valid'); + }); + + test('a single flipped character anywhere in the payload is detected', () => { + const envelope = fixtureValue('valid.json'); + (envelope['repository'] as Record)['revision'] = + '1e0f3c9a8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e30'; + const validation = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); + expect(validation.outcome).toBe('valid'); + if (validation.outcome !== 'valid') return; + + expect(checkEnvelopeDigest(validation.validated).outcome).toBe('digest-mismatch'); + }); + + test('the declared digest is never trusted unconditionally', () => { + // An envelope declaring a digest of the right *shape* but the wrong value is + // rejected. A consumer that read `digest` and believed it would pass this. + const envelope = fixtureValue('valid.json'); + envelope['digest'] = 'a'.repeat(64); + const validation = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); + expect(validation.outcome).toBe('valid'); + if (validation.outcome !== 'valid') return; + + const result = checkEnvelopeDigest(validation.validated); + expect(result.outcome).toBe('digest-mismatch'); + expect(result.declaredDigest).toBe('a'.repeat(64)); + }); +}); + +describe('the guarantee scope travels with the result', () => { + test('every digest result carries the scope statement', () => { + for (const fixture of ['valid.json', 'tampered.json']) { + const validation = validateEnvelope(fixtureText(fixture), VALIDATE_OPTIONS); + if (validation.outcome !== 'valid') throw new Error(`${fixture} failed validation`); + expect(checkEnvelopeDigest(validation.validated).guaranteeScope).toBe(DIGEST_GUARANTEE_SCOPE); + } + }); + + test('the scope statement names both limits and claims neither strength', () => { + expect(DIGEST_GUARANTEE_SCOPE).toContain('accidental corruption and naive mutation only'); + expect(DIGEST_GUARANTEE_SCOPE).toContain('Does not resist an adversary'); + expect(DIGEST_GUARANTEE_SCOPE).toContain('integrity, not correctness'); + expect(DIGEST_GUARANTEE_SCOPE.toLowerCase()).not.toContain('tamper-proof'); + expect(DIGEST_GUARANTEE_SCOPE.toLowerCase()).not.toContain('tamper-resistant'); + }); +}); diff --git a/packages/catalog-envelope/test/fixtures/README.md b/packages/catalog-envelope/test/fixtures/README.md new file mode 100644 index 00000000..1f9375ca --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/README.md @@ -0,0 +1,47 @@ +# Envelope fixtures + +Hand-authored, **entirely synthetic** snapshot envelopes. No external adopter is +involved and no third-party content appears in any of them — +[`snapshot-envelope.md`](../../../../specs/009-catalog-binding-viability/contracts/snapshot-envelope.md) +§7 requires that, and it is why what these fixtures prove is mechanical and +offline, not external, third-party, or community validation (ADR-0014 rung 3). + +[`author.ts`](./author.ts) is the record of how each was constructed. Re-running +it rewrites the same bytes. + +## Two synthetic repositories + +| | Repository id | Revision | +|---|---|---| +| **A** | `github.com/mbeacom/adrkit-envelope-consumer-fixture` | `1e0f3c9a8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f` | +| **A, stale** | same | `f0e9d8c7b6a594837261504938271605948372a1` | +| **B** | `github.com/mbeacom/adrkit-envelope-consumer-fixture-second` | `2b7c4d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c` | + +`sources/` is the `sourceBaseDir` for step 4. Its two YAML files are the bytes +each envelope's `sources[].digest` is checked against. + +## The files + +| File | Construction | Expected outcome | +|---|---|---| +| `valid.json` | Repository A, three entities covering all three ownership states, correct source digest, correct self-digest | Admitted | +| `all-annotation-absent.json` | As `valid.json` but **every** entity is `annotation-absent`, with `completeness.identityOnly: false` | **Accepted.** The contrast case: step 5 reads the boolean, never the ownership-state distribution (`snapshot-envelope.md` §7 row 1b) | +| `malformed-invalid-json.json` | `valid.json` truncated mid-token | Rejected at **step 1**, `invalid-json` | +| `malformed-missing-or-wrong-field.json` | `entities[1].identity.allRefs` replaced with a string | Rejected at **step 2**, `missing-or-wrong-required-field`. Deliberately nested two levels deep — a top-level-only shape check would let it through | +| `malformed-unrecognized.json` | `globDialect.engine` set to `"minimatch"` | Rejected at **step 3**, `unrecognized-schema-or-dialect-or-capability` | +| `malformed-missing-source-digest.json` | The one `sources` entry omits `digest`; otherwise well-formed | Rejected at **step 4**, `missing-source-digest`. It must **pass** steps 2 and 3 — that is the whole point of the fixture | +| `malformed-identity-only.json` | `completeness.identityOnly: true`, everything else correct **including the source digest**, so steps 1–4 all pass | Rejected at **step 5**, `identity-only-true` | +| `tampered.json` | `entities[0].derivedPaths` gained an element **after** the digest was computed | Passes all five steps; refused on **digest mismatch** | +| `stale.json` | Repository A at a different revision, self-digest **recomputed over its own actual content** | Passes all five steps **and the digest**; refused as **stale** | +| `wrong-repository.json` | Repository B, valid in its own right, self-digest recomputed over its own content | Refused as **misidentified** by a consumer expecting A; **accepted** by an isolation query alongside `valid.json` | + +The last two are the constructions that are easy to get wrong. Their digests are +recomputed over their mutated content on purpose, so that the rejection is +attributable specifically to staleness or identity and **never** to a +coincidental digest failure (`snapshot-envelope.md` §4, §5). + +`wrong-repository.json` does double duty, and the two roles must not be +conflated: §5's mismatch-and-reject (a consumer that expected exactly one +repository) and §6's filter-and-isolate (a consumer deliberately holding +several). It is the same file because it is the same envelope — what differs is +what the consumer was configured to expect. diff --git a/packages/catalog-envelope/test/fixtures/all-annotation-absent.json b/packages/catalog-envelope/test/fixtures/all-annotation-absent.json new file mode 100644 index 00000000..71ca9ee3 --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/all-annotation-absent.json @@ -0,0 +1,79 @@ +{ + "schemaVersion": "1", + "repository": { + "id": "github.com/mbeacom/adrkit-envelope-consumer-fixture", + "revision": "1e0f3c9a8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f" + }, + "generatorVersion": "010-consumer-fixture-0.0.0", + "globDialect": { + "engine": "picomatch", + "version": "4.0.5", + "options": { + "dot": false, + "nocase": false, + "nonegate": true + } + }, + "capabilities": [ + "pathOwnership" + ], + "completeness": { + "wholeCatalog": false, + "identityOnly": false + }, + "sources": [ + { + "path": "catalog-info.yaml", + "digestAlgorithm": "sha256", + "digest": "6f31b25031729ba75d4770966a589227570ee918e9fdf0d8cee0273aab285778" + } + ], + "entities": [ + { + "identity": { + "canonicalId": "component:default/payments", + "allRefs": [ + "component:default/payments" + ] + }, + "ownershipState": "annotation-absent", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 0 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/ledger", + "allRefs": [ + "component:default/ledger" + ] + }, + "ownershipState": "annotation-absent", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 1 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/gateway", + "allRefs": [ + "component:default/gateway" + ] + }, + "ownershipState": "annotation-absent", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 2 + }, + "provenance": "synthetic" + } + ], + "digest": "80dce798affc428a623e82dcaf836e1f52d4da355ccb5b98fec6db85fc20adb4" +} diff --git a/packages/catalog-envelope/test/fixtures/author.ts b/packages/catalog-envelope/test/fixtures/author.ts new file mode 100644 index 00000000..00da3061 --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/author.ts @@ -0,0 +1,193 @@ +/** + * The record of how every fixture in this directory was constructed. + * + * The fixtures themselves are the artifacts — hand-reviewable JSON, committed, + * and asserted on directly by the tests. This file exists so that *how* each + * one was derived from the valid envelope is auditable rather than folklore, + * and so that the three digest-sensitive constructions (`tampered`, `stale`, + * `wrong-repository`) can be reproduced byte-for-byte. + * + * It is deterministic: re-running it rewrites the same bytes. It is not a test + * and is not run by `bun test`. + * + * ```bash + * bun run packages/catalog-envelope/test/fixtures/author.ts + * ``` + * + * **Everything here is synthetic.** No external adopter is involved and no + * third-party content appears in any fixture — `snapshot-envelope.md` §7 + * requires exactly that, and it is why nothing these fixtures prove amounts to + * external or community validation (ADR-0014 rung 3). + */ +import { createHash } from 'node:crypto'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { canonicalStringify } from '@adrkit/core'; + +const FIX = import.meta.dir; +const SRC = join(FIX, 'sources'); +mkdirSync(SRC, { recursive: true }); + +const REPO_A = 'github.com/mbeacom/adrkit-envelope-consumer-fixture'; +const REV_A = '1e0f3c9a8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f'; +const REV_STALE = 'f0e9d8c7b6a594837261504938271605948372a1'; +const REPO_B = 'github.com/mbeacom/adrkit-envelope-consumer-fixture-second'; +const REV_B = '2b7c4d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c'; + +const catalogInfoA = `# Synthetic fixture descriptor for @adrkit/catalog-envelope tests. +# Hand-authored. No external adopter is involved, and nothing here is +# third-party content. +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: payments + namespace: default +spec: + type: service + owner: team-payments + lifecycle: production +`; + +const catalogInfoB = `# Synthetic fixture descriptor for the second-repository envelope. +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: billing + namespace: default +spec: + type: service + owner: team-billing + lifecycle: production +`; + +writeFileSync(join(SRC, 'catalog-info.yaml'), catalogInfoA, 'utf8'); +writeFileSync(join(SRC, 'second-catalog-info.yaml'), catalogInfoB, 'utf8'); + +const sha = (text: string) => createHash('sha256').update(text, 'utf8').digest('hex'); + +const sourcesA = [ + { path: 'catalog-info.yaml', digestAlgorithm: 'sha256', digest: sha(catalogInfoA) }, +]; +const sourcesB = [ + { path: 'second-catalog-info.yaml', digestAlgorithm: 'sha256', digest: sha(catalogInfoB) }, +]; + +const GLOB = { engine: 'picomatch', version: '4.0.5', options: { dot: false, nocase: false, nonegate: true } }; + +const entitiesA = [ + { + identity: { canonicalId: 'component:default/payments', allRefs: ['component:default/payments'] }, + ownershipState: 'explicit-paths', + derivedPaths: ['apis/payments/**', 'packages/payments/**'], + sourceDocument: { sourcePath: 'catalog-info.yaml', documentIndexInFile: 0 }, + provenance: 'synthetic', + }, + { + identity: { canonicalId: 'component:default/ledger', allRefs: ['component:default/ledger'] }, + ownershipState: 'explicit-empty', + derivedPaths: [], + sourceDocument: { sourcePath: 'catalog-info.yaml', documentIndexInFile: 1 }, + provenance: 'synthetic', + }, + { + identity: { canonicalId: 'component:default/gateway', allRefs: ['component:default/gateway'] }, + ownershipState: 'annotation-absent', + derivedPaths: [], + sourceDocument: { sourcePath: 'catalog-info.yaml', documentIndexInFile: 2 }, + provenance: 'synthetic', + }, +]; + +const entitiesB = [ + { + identity: { canonicalId: 'component:default/billing', allRefs: ['component:default/billing'] }, + ownershipState: 'explicit-paths', + derivedPaths: ['services/billing/**'], + sourceDocument: { sourcePath: 'second-catalog-info.yaml', documentIndexInFile: 0 }, + provenance: 'synthetic', + }, +]; + +type Env = Record; + +function base(repoId: string, revision: string, sources: unknown[], entities: unknown[]): Env { + return { + schemaVersion: '1', + repository: { id: repoId, revision }, + generatorVersion: '010-consumer-fixture-0.0.0', + globDialect: GLOB, + capabilities: ['pathOwnership'], + completeness: { wholeCatalog: false, identityOnly: false }, + sources, + entities, + }; +} + +function sealed(env: Env): Env { + const digest = createHash('sha256').update(canonicalStringify(env), 'utf8').digest('hex'); + return { ...env, digest }; +} + +function write(name: string, value: unknown): void { + writeFileSync(join(FIX, name), `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; + +// --- valid --------------------------------------------------------------- +const valid = sealed(base(REPO_A, REV_A, sourcesA, entitiesA)); +write('valid.json', valid); + +// --- contrast case: all entities annotation-absent, identityOnly false ---- +const allAbsent = sealed( + base( + REPO_A, + REV_A, + sourcesA, + entitiesA.map((e) => ({ ...clone(e), ownershipState: 'annotation-absent', derivedPaths: [] })), + ), +); +write('all-annotation-absent.json', allAbsent); + +// --- step 1: invalid JSON ------------------------------------------------ +writeFileSync( + join(FIX, 'malformed-invalid-json.json'), + `${JSON.stringify(valid, null, 2).slice(0, -220)}\n`, + 'utf8', +); + +// --- step 2: wrong type at a nested level -------------------------------- +const step2 = clone(valid) as Env; +(step2['entities'] as Env[])[1]!['identity'] = { canonicalId: 'component:default/ledger', allRefs: 'component:default/ledger' }; +write('malformed-missing-or-wrong-field.json', step2); + +// --- step 3: unrecognized dialect engine --------------------------------- +const step3 = clone(valid) as Env; +(step3['globDialect'] as Env)['engine'] = 'minimatch'; +write('malformed-unrecognized.json', step3); + +// --- step 4: a source entry that omits its digest ------------------------- +const step4 = clone(valid) as Env; +step4['sources'] = [{ path: 'catalog-info.yaml', digestAlgorithm: 'sha256' }]; +write('malformed-missing-source-digest.json', step4); + +// --- step 5: identityOnly true ------------------------------------------- +const step5raw = base(REPO_A, REV_A, sourcesA, entitiesA); +(step5raw['completeness'] as Env)['identityOnly'] = true; +write('malformed-identity-only.json', sealed(step5raw)); + +// --- tampered: derivedPaths mutated AFTER the digest was computed --------- +const tampered = clone(valid) as Env; +(tampered['entities'] as Env[])[0]!['derivedPaths'] = ['apis/payments/**', 'packages/payments/**', 'infra/**']; +write('tampered.json', tampered); + +// --- stale: different revision, digest recomputed over actual content ----- +write('stale.json', sealed(base(REPO_A, REV_STALE, sourcesA, entitiesA))); + +// --- wrong repository: valid in its own right, different repository id ---- +write('wrong-repository.json', sealed(base(REPO_B, REV_B, sourcesB, entitiesB))); + +console.log('repoA', REPO_A, REV_A); +console.log('repoB', REPO_B, REV_B); +console.log('stale', REV_STALE); +console.log('valid.digest', valid['digest']); diff --git a/packages/catalog-envelope/test/fixtures/malformed-identity-only.json b/packages/catalog-envelope/test/fixtures/malformed-identity-only.json new file mode 100644 index 00000000..384c2146 --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/malformed-identity-only.json @@ -0,0 +1,82 @@ +{ + "schemaVersion": "1", + "repository": { + "id": "github.com/mbeacom/adrkit-envelope-consumer-fixture", + "revision": "1e0f3c9a8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f" + }, + "generatorVersion": "010-consumer-fixture-0.0.0", + "globDialect": { + "engine": "picomatch", + "version": "4.0.5", + "options": { + "dot": false, + "nocase": false, + "nonegate": true + } + }, + "capabilities": [ + "pathOwnership" + ], + "completeness": { + "wholeCatalog": false, + "identityOnly": true + }, + "sources": [ + { + "path": "catalog-info.yaml", + "digestAlgorithm": "sha256", + "digest": "6f31b25031729ba75d4770966a589227570ee918e9fdf0d8cee0273aab285778" + } + ], + "entities": [ + { + "identity": { + "canonicalId": "component:default/payments", + "allRefs": [ + "component:default/payments" + ] + }, + "ownershipState": "explicit-paths", + "derivedPaths": [ + "apis/payments/**", + "packages/payments/**" + ], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 0 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/ledger", + "allRefs": [ + "component:default/ledger" + ] + }, + "ownershipState": "explicit-empty", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 1 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/gateway", + "allRefs": [ + "component:default/gateway" + ] + }, + "ownershipState": "annotation-absent", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 2 + }, + "provenance": "synthetic" + } + ], + "digest": "b6f5df75d68eff556a883924f220cb17dc2c7fc4a9cceb72c5bf66a16af19e07" +} diff --git a/packages/catalog-envelope/test/fixtures/malformed-invalid-json.json b/packages/catalog-envelope/test/fixtures/malformed-invalid-json.json new file mode 100644 index 00000000..15497bfe --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/malformed-invalid-json.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": "1", + "repository": { + "id": "github.com/mbeacom/adrkit-envelope-consumer-fixture", + "revision": "1e0f3c9a8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f" + }, + "generatorVersion": "010-consumer-fixture-0.0.0", + "globDialect": { + "engine": "picomatch", + "version": "4.0.5", + "options": { + "dot": false, + "nocase": false, + "nonegate": true + } + }, + "capabilities": [ + "pathOwnership" + ], + "completeness": { + "wholeCatalog": false, + "identityOnly": false + }, + "sources": [ + { + "path": "catalog-info.yaml", + "digestAlgorithm": "sha256", + "digest": "6f31b25031729ba75d4770966a589227570ee918e9fdf0d8cee0273aab285778" + } + ], + "entities": [ + { + "identity": { + "canonicalId": "component:default/payments", + "allRefs": [ + "component:default/payments" + ] + }, + "ownershipState": "explicit-paths", + "derivedPaths": [ + "apis/payments/**", + "packages/payments/**" + ], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 0 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/ledger", + "allRefs": [ + "component:default/ledger" + ] + }, + "ownershipState": "explicit-empty", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 1 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/gateway", + "allRefs": [ + "component:default/gateway" + ] + }, + "ownershipState": "annotation-absent", + "derivedPaths": [], + "sourceD diff --git a/packages/catalog-envelope/test/fixtures/malformed-missing-or-wrong-field.json b/packages/catalog-envelope/test/fixtures/malformed-missing-or-wrong-field.json new file mode 100644 index 00000000..8b0440ac --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/malformed-missing-or-wrong-field.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": "1", + "repository": { + "id": "github.com/mbeacom/adrkit-envelope-consumer-fixture", + "revision": "1e0f3c9a8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f" + }, + "generatorVersion": "010-consumer-fixture-0.0.0", + "globDialect": { + "engine": "picomatch", + "version": "4.0.5", + "options": { + "dot": false, + "nocase": false, + "nonegate": true + } + }, + "capabilities": [ + "pathOwnership" + ], + "completeness": { + "wholeCatalog": false, + "identityOnly": false + }, + "sources": [ + { + "path": "catalog-info.yaml", + "digestAlgorithm": "sha256", + "digest": "6f31b25031729ba75d4770966a589227570ee918e9fdf0d8cee0273aab285778" + } + ], + "entities": [ + { + "identity": { + "canonicalId": "component:default/payments", + "allRefs": [ + "component:default/payments" + ] + }, + "ownershipState": "explicit-paths", + "derivedPaths": [ + "apis/payments/**", + "packages/payments/**" + ], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 0 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/ledger", + "allRefs": "component:default/ledger" + }, + "ownershipState": "explicit-empty", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 1 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/gateway", + "allRefs": [ + "component:default/gateway" + ] + }, + "ownershipState": "annotation-absent", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 2 + }, + "provenance": "synthetic" + } + ], + "digest": "08b544b48fb8c3f1672c249623ad7bffb3b025cd2a8cabea208d98800e279df2" +} diff --git a/packages/catalog-envelope/test/fixtures/malformed-missing-source-digest.json b/packages/catalog-envelope/test/fixtures/malformed-missing-source-digest.json new file mode 100644 index 00000000..e13a1de1 --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/malformed-missing-source-digest.json @@ -0,0 +1,81 @@ +{ + "schemaVersion": "1", + "repository": { + "id": "github.com/mbeacom/adrkit-envelope-consumer-fixture", + "revision": "1e0f3c9a8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f" + }, + "generatorVersion": "010-consumer-fixture-0.0.0", + "globDialect": { + "engine": "picomatch", + "version": "4.0.5", + "options": { + "dot": false, + "nocase": false, + "nonegate": true + } + }, + "capabilities": [ + "pathOwnership" + ], + "completeness": { + "wholeCatalog": false, + "identityOnly": false + }, + "sources": [ + { + "path": "catalog-info.yaml", + "digestAlgorithm": "sha256" + } + ], + "entities": [ + { + "identity": { + "canonicalId": "component:default/payments", + "allRefs": [ + "component:default/payments" + ] + }, + "ownershipState": "explicit-paths", + "derivedPaths": [ + "apis/payments/**", + "packages/payments/**" + ], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 0 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/ledger", + "allRefs": [ + "component:default/ledger" + ] + }, + "ownershipState": "explicit-empty", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 1 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/gateway", + "allRefs": [ + "component:default/gateway" + ] + }, + "ownershipState": "annotation-absent", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 2 + }, + "provenance": "synthetic" + } + ], + "digest": "08b544b48fb8c3f1672c249623ad7bffb3b025cd2a8cabea208d98800e279df2" +} diff --git a/packages/catalog-envelope/test/fixtures/malformed-unrecognized.json b/packages/catalog-envelope/test/fixtures/malformed-unrecognized.json new file mode 100644 index 00000000..53c41ce4 --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/malformed-unrecognized.json @@ -0,0 +1,82 @@ +{ + "schemaVersion": "1", + "repository": { + "id": "github.com/mbeacom/adrkit-envelope-consumer-fixture", + "revision": "1e0f3c9a8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f" + }, + "generatorVersion": "010-consumer-fixture-0.0.0", + "globDialect": { + "engine": "minimatch", + "version": "4.0.5", + "options": { + "dot": false, + "nocase": false, + "nonegate": true + } + }, + "capabilities": [ + "pathOwnership" + ], + "completeness": { + "wholeCatalog": false, + "identityOnly": false + }, + "sources": [ + { + "path": "catalog-info.yaml", + "digestAlgorithm": "sha256", + "digest": "6f31b25031729ba75d4770966a589227570ee918e9fdf0d8cee0273aab285778" + } + ], + "entities": [ + { + "identity": { + "canonicalId": "component:default/payments", + "allRefs": [ + "component:default/payments" + ] + }, + "ownershipState": "explicit-paths", + "derivedPaths": [ + "apis/payments/**", + "packages/payments/**" + ], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 0 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/ledger", + "allRefs": [ + "component:default/ledger" + ] + }, + "ownershipState": "explicit-empty", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 1 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/gateway", + "allRefs": [ + "component:default/gateway" + ] + }, + "ownershipState": "annotation-absent", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 2 + }, + "provenance": "synthetic" + } + ], + "digest": "08b544b48fb8c3f1672c249623ad7bffb3b025cd2a8cabea208d98800e279df2" +} diff --git a/packages/catalog-envelope/test/fixtures/sources/catalog-info.yaml b/packages/catalog-envelope/test/fixtures/sources/catalog-info.yaml new file mode 100644 index 00000000..31865adf --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/sources/catalog-info.yaml @@ -0,0 +1,12 @@ +# Synthetic fixture descriptor for @adrkit/catalog-envelope tests. +# Hand-authored. No external adopter is involved, and nothing here is +# third-party content. +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: payments + namespace: default +spec: + type: service + owner: team-payments + lifecycle: production diff --git a/packages/catalog-envelope/test/fixtures/sources/second-catalog-info.yaml b/packages/catalog-envelope/test/fixtures/sources/second-catalog-info.yaml new file mode 100644 index 00000000..663bab37 --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/sources/second-catalog-info.yaml @@ -0,0 +1,10 @@ +# Synthetic fixture descriptor for the second-repository envelope. +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: billing + namespace: default +spec: + type: service + owner: team-billing + lifecycle: production diff --git a/packages/catalog-envelope/test/fixtures/stale.json b/packages/catalog-envelope/test/fixtures/stale.json new file mode 100644 index 00000000..2b563e21 --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/stale.json @@ -0,0 +1,82 @@ +{ + "schemaVersion": "1", + "repository": { + "id": "github.com/mbeacom/adrkit-envelope-consumer-fixture", + "revision": "f0e9d8c7b6a594837261504938271605948372a1" + }, + "generatorVersion": "010-consumer-fixture-0.0.0", + "globDialect": { + "engine": "picomatch", + "version": "4.0.5", + "options": { + "dot": false, + "nocase": false, + "nonegate": true + } + }, + "capabilities": [ + "pathOwnership" + ], + "completeness": { + "wholeCatalog": false, + "identityOnly": false + }, + "sources": [ + { + "path": "catalog-info.yaml", + "digestAlgorithm": "sha256", + "digest": "6f31b25031729ba75d4770966a589227570ee918e9fdf0d8cee0273aab285778" + } + ], + "entities": [ + { + "identity": { + "canonicalId": "component:default/payments", + "allRefs": [ + "component:default/payments" + ] + }, + "ownershipState": "explicit-paths", + "derivedPaths": [ + "apis/payments/**", + "packages/payments/**" + ], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 0 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/ledger", + "allRefs": [ + "component:default/ledger" + ] + }, + "ownershipState": "explicit-empty", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 1 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/gateway", + "allRefs": [ + "component:default/gateway" + ] + }, + "ownershipState": "annotation-absent", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 2 + }, + "provenance": "synthetic" + } + ], + "digest": "90e075fb107ba7f045ac2b1fc6c82ccc33baf12b22e4610cc17abef948179423" +} diff --git a/packages/catalog-envelope/test/fixtures/tampered.json b/packages/catalog-envelope/test/fixtures/tampered.json new file mode 100644 index 00000000..37563ff1 --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/tampered.json @@ -0,0 +1,83 @@ +{ + "schemaVersion": "1", + "repository": { + "id": "github.com/mbeacom/adrkit-envelope-consumer-fixture", + "revision": "1e0f3c9a8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f" + }, + "generatorVersion": "010-consumer-fixture-0.0.0", + "globDialect": { + "engine": "picomatch", + "version": "4.0.5", + "options": { + "dot": false, + "nocase": false, + "nonegate": true + } + }, + "capabilities": [ + "pathOwnership" + ], + "completeness": { + "wholeCatalog": false, + "identityOnly": false + }, + "sources": [ + { + "path": "catalog-info.yaml", + "digestAlgorithm": "sha256", + "digest": "6f31b25031729ba75d4770966a589227570ee918e9fdf0d8cee0273aab285778" + } + ], + "entities": [ + { + "identity": { + "canonicalId": "component:default/payments", + "allRefs": [ + "component:default/payments" + ] + }, + "ownershipState": "explicit-paths", + "derivedPaths": [ + "apis/payments/**", + "packages/payments/**", + "infra/**" + ], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 0 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/ledger", + "allRefs": [ + "component:default/ledger" + ] + }, + "ownershipState": "explicit-empty", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 1 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/gateway", + "allRefs": [ + "component:default/gateway" + ] + }, + "ownershipState": "annotation-absent", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 2 + }, + "provenance": "synthetic" + } + ], + "digest": "08b544b48fb8c3f1672c249623ad7bffb3b025cd2a8cabea208d98800e279df2" +} diff --git a/packages/catalog-envelope/test/fixtures/valid.json b/packages/catalog-envelope/test/fixtures/valid.json new file mode 100644 index 00000000..103276ab --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/valid.json @@ -0,0 +1,82 @@ +{ + "schemaVersion": "1", + "repository": { + "id": "github.com/mbeacom/adrkit-envelope-consumer-fixture", + "revision": "1e0f3c9a8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f" + }, + "generatorVersion": "010-consumer-fixture-0.0.0", + "globDialect": { + "engine": "picomatch", + "version": "4.0.5", + "options": { + "dot": false, + "nocase": false, + "nonegate": true + } + }, + "capabilities": [ + "pathOwnership" + ], + "completeness": { + "wholeCatalog": false, + "identityOnly": false + }, + "sources": [ + { + "path": "catalog-info.yaml", + "digestAlgorithm": "sha256", + "digest": "6f31b25031729ba75d4770966a589227570ee918e9fdf0d8cee0273aab285778" + } + ], + "entities": [ + { + "identity": { + "canonicalId": "component:default/payments", + "allRefs": [ + "component:default/payments" + ] + }, + "ownershipState": "explicit-paths", + "derivedPaths": [ + "apis/payments/**", + "packages/payments/**" + ], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 0 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/ledger", + "allRefs": [ + "component:default/ledger" + ] + }, + "ownershipState": "explicit-empty", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 1 + }, + "provenance": "synthetic" + }, + { + "identity": { + "canonicalId": "component:default/gateway", + "allRefs": [ + "component:default/gateway" + ] + }, + "ownershipState": "annotation-absent", + "derivedPaths": [], + "sourceDocument": { + "sourcePath": "catalog-info.yaml", + "documentIndexInFile": 2 + }, + "provenance": "synthetic" + } + ], + "digest": "08b544b48fb8c3f1672c249623ad7bffb3b025cd2a8cabea208d98800e279df2" +} diff --git a/packages/catalog-envelope/test/fixtures/wrong-repository.json b/packages/catalog-envelope/test/fixtures/wrong-repository.json new file mode 100644 index 00000000..053abf1a --- /dev/null +++ b/packages/catalog-envelope/test/fixtures/wrong-repository.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": "1", + "repository": { + "id": "github.com/mbeacom/adrkit-envelope-consumer-fixture-second", + "revision": "2b7c4d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c" + }, + "generatorVersion": "010-consumer-fixture-0.0.0", + "globDialect": { + "engine": "picomatch", + "version": "4.0.5", + "options": { + "dot": false, + "nocase": false, + "nonegate": true + } + }, + "capabilities": [ + "pathOwnership" + ], + "completeness": { + "wholeCatalog": false, + "identityOnly": false + }, + "sources": [ + { + "path": "second-catalog-info.yaml", + "digestAlgorithm": "sha256", + "digest": "217dcafc96abbab7c259c8d81e3b73ade7f7c73d21f61af0eb9e260a785fc280" + } + ], + "entities": [ + { + "identity": { + "canonicalId": "component:default/billing", + "allRefs": [ + "component:default/billing" + ] + }, + "ownershipState": "explicit-paths", + "derivedPaths": [ + "services/billing/**" + ], + "sourceDocument": { + "sourcePath": "second-catalog-info.yaml", + "documentIndexInFile": 0 + }, + "provenance": "synthetic" + } + ], + "digest": "27ee38bdc17a8c68ae5d147a47c97ce66c12492f4d012570af305d69ece81d45" +} diff --git a/packages/catalog-envelope/test/helpers.ts b/packages/catalog-envelope/test/helpers.ts new file mode 100644 index 00000000..8282eb42 --- /dev/null +++ b/packages/catalog-envelope/test/helpers.ts @@ -0,0 +1,35 @@ +/** + * Shared fixture access for the `@adrkit/catalog-envelope` test suite. + * + * Everything under `fixtures/` is synthetic and hand-authored; see + * `fixtures/README.md` for how each was constructed. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export const FIXTURE_DIR = join(import.meta.dir, 'fixtures'); +export const SOURCE_BASE_DIR = join(FIXTURE_DIR, 'sources'); + +export const REPOSITORY_A = 'github.com/mbeacom/adrkit-envelope-consumer-fixture'; +export const REVISION_A = '1e0f3c9a8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f'; +export const REVISION_A_STALE = 'f0e9d8c7b6a594837261504938271605948372a1'; +export const REPOSITORY_B = 'github.com/mbeacom/adrkit-envelope-consumer-fixture-second'; +export const REVISION_B = '2b7c4d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c'; + +export function fixtureText(name: string): string { + return readFileSync(join(FIXTURE_DIR, name), 'utf8'); +} + +/** A parsed copy, safe to mutate. */ +export function fixtureValue(name: string): Record { + return JSON.parse(fixtureText(name)) as Record; +} + +export const VALIDATE_OPTIONS = { sourceBaseDir: SOURCE_BASE_DIR } as const; + +export const ADMIT_OPTIONS_A = { + sourceBaseDir: SOURCE_BASE_DIR, + expectedRepositoryId: REPOSITORY_A, + expectedRevision: REVISION_A, +} as const; diff --git a/packages/catalog-envelope/test/identity.test.ts b/packages/catalog-envelope/test/identity.test.ts new file mode 100644 index 00000000..e5b053b3 --- /dev/null +++ b/packages/catalog-envelope/test/identity.test.ts @@ -0,0 +1,249 @@ +/** + * Staleness as **exact revision inequality** and repository identity as two + * outcomes that must not be conflated (`snapshot-envelope.md` §4, §5, §6; + * `data-model.md` §13, §14; `spec.md` FR-047, FR-048; T032, T033). + * + * The single most important thing asserted here is the **isolation** of these + * rejections from the digest check. `stale.json` and `wrong-repository.json` + * both carry digests recomputed over their own mutated content, so both pass + * §3 cleanly. If either were rejected on a digest mismatch instead, the test + * would still be green on "rejected" while proving nothing about staleness or + * identity at all. + */ + +import { describe, expect, test } from 'bun:test'; +import { + STALENESS_COMPARISON, + admitEnvelope, + checkEnvelopeDigest, + checkRepositoryIdentity, + checkStaleness, + queryEntitiesForRepository, + validateEnvelope, +} from '../src/index.ts'; +import { + ADMIT_OPTIONS_A, + REPOSITORY_A, + REPOSITORY_B, + REVISION_A, + REVISION_A_STALE, + REVISION_B, + SOURCE_BASE_DIR, + VALIDATE_OPTIONS, + fixtureText, +} from './helpers.ts'; + +function validated(name: string) { + const result = validateEnvelope(fixtureText(name), VALIDATE_OPTIONS); + if (result.outcome !== 'valid') { + throw new Error(`${name} did not pass the five steps: ${result.reason} — ${result.detail}`); + } + return result.validated; +} + +describe('staleness is exact inequality', () => { + test('the stale fixture passes the digest check cleanly first', () => { + // Without this, the staleness assertion below would be unfalsifiable: a + // digest failure would produce a rejection that looks the same from outside. + expect(checkEnvelopeDigest(validated('stale.json')).outcome).toBe('match'); + }); + + test('a different revision for the same repository is stale', () => { + const result = checkStaleness(validated('stale.json'), REVISION_A); + + expect(result.outcome).toBe('stale-revision'); + expect(result.declaredRevision).toBe(REVISION_A_STALE); + expect(result.expectedRevision).toBe(REVISION_A); + expect(result.detail).toContain('not exactly equal to the configured expected-current revision'); + }); + + test('an exactly equal revision is ok', () => { + const result = checkStaleness(validated('valid.json'), REVISION_A); + expect(result.outcome).toBe('ok'); + expect(result.declaredRevision).toBe(REVISION_A); + }); + + test('inequality is symmetric — direction is never inferred', () => { + // The point of "exact inequality, not ordering": swapping which SHA is + // expected and which is declared produces the same verdict. A chronological + // implementation would call one of these two directions acceptable. + const forwards = checkStaleness(validated('stale.json'), REVISION_A); + const backwards = checkStaleness(validated('valid.json'), REVISION_A_STALE); + + expect(forwards.outcome).toBe('stale-revision'); + expect(backwards.outcome).toBe('stale-revision'); + }); + + test('a lexicographically smaller revision is just as stale as a larger one', () => { + for (const expected of ['0'.repeat(40), 'f'.repeat(40)]) { + expect(checkStaleness(validated('valid.json'), expected).outcome).toBe('stale-revision'); + } + }); + + test('with no expectation configured, the outcome is not-configured rather than ok', () => { + // ADR-0016's central failure shape: an unchecked envelope must not render + // identically to a checked one. + const result = checkStaleness(validated('stale.json')); + expect(result.outcome).toBe('not-configured'); + expect(result.outcome).not.toBe('ok'); + expect(result.detail).toContain('was not compared against anything'); + }); + + test('the comparison is declared on the result and names no ordering', () => { + const result = checkStaleness(validated('valid.json'), REVISION_A); + expect(result.comparison).toBe(STALENESS_COMPARISON); + expect(STALENESS_COMPARISON).toContain('exact string inequality'); + expect(STALENESS_COMPARISON).toContain('never a chronological, ancestry, or ordering comparison'); + }); + + test('an expectation configured for another repository yields no staleness verdict', () => { + // `snapshot-envelope.md` §4 keys the expected-current revision to a + // repository id. Repository A's revision says nothing about an envelope + // describing repository B, and judging it stale would be the §5/§6 + // conflation wearing a staleness label. + const result = checkStaleness(validated('wrong-repository.json'), REVISION_A, REPOSITORY_A); + + expect(result.outcome).toBe('not-applicable-different-repository'); + expect(result.outcome).not.toBe('stale-revision'); + expect(result.detail).toContain(`configured for repository ${REPOSITORY_A}`); + expect(result.detail).toContain(REPOSITORY_B); + }); + + test('the scoping does not weaken the check for the repository it is about', () => { + // Same call shape, same repository — still stale. + const result = checkStaleness(validated('stale.json'), REVISION_A, REPOSITORY_A); + expect(result.outcome).toBe('stale-revision'); + }); + + test('admission refuses the stale fixture at the staleness stage, not the digest stage', () => { + const result = admitEnvelope(fixtureText('stale.json'), ADMIT_OPTIONS_A); + + expect(result.outcome).toBe('refused'); + if (result.outcome !== 'refused') return; + expect(result.refusedAt).toBe('staleness'); + expect(result.reason).toBe('stale-revision'); + expect(result.detail).toContain(REVISION_A_STALE); + expect(result.detail).toContain(REVISION_A); + }); +}); + +describe('repository identity mismatch is a rejection', () => { + test('the wrong-repository fixture passes the digest check cleanly first', () => { + expect(checkEnvelopeDigest(validated('wrong-repository.json')).outcome).toBe('match'); + }); + + test('a different repository id is a mismatch', () => { + const result = checkRepositoryIdentity(validated('wrong-repository.json'), REPOSITORY_A); + + expect(result.outcome).toBe('repository-identity-mismatch'); + expect(result.declaredRepositoryId).toBe(REPOSITORY_B); + expect(result.expectedRepositoryId).toBe(REPOSITORY_A); + expect(result.detail).toContain('is not the repository'); + }); + + test('the expected repository id is ok', () => { + const result = checkRepositoryIdentity(validated('valid.json'), REPOSITORY_A); + expect(result.outcome).toBe('ok'); + expect(result.declaredRepositoryId).toBe(REPOSITORY_A); + }); + + test('with no expectation configured, the outcome is not-configured rather than ok', () => { + const result = checkRepositoryIdentity(validated('wrong-repository.json')); + expect(result.outcome).toBe('not-configured'); + expect(result.outcome).not.toBe('ok'); + }); + + test('admission refuses at the identity stage, after staleness has passed', () => { + // Configured for repository A *including* a revision expectation. Staleness + // is keyed to repository A, so it produces no verdict for a repository-B + // envelope and the refusal is attributable to identity — not to a revision + // comparison that was never meaningful. + const result = admitEnvelope(fixtureText('wrong-repository.json'), ADMIT_OPTIONS_A); + + expect(result.outcome).toBe('refused'); + if (result.outcome !== 'refused') return; + expect(result.refusedAt).toBe('repository-identity'); + expect(result.reason).toBe('repository-identity-mismatch'); + expect(result.detail).toContain(REPOSITORY_B); + expect(result.detail).toContain(REPOSITORY_A); + }); + + test('the same refusal holds with no revision expectation at all', () => { + const result = admitEnvelope(fixtureText('wrong-repository.json'), { + sourceBaseDir: SOURCE_BASE_DIR, + expectedRepositoryId: REPOSITORY_A, + }); + + expect(result.outcome).toBe('refused'); + if (result.outcome !== 'refused') return; + expect(result.refusedAt).toBe('repository-identity'); + }); +}); + +describe('repository isolation is acceptance, not rejection', () => { + test('a valid envelope from a different repository is admitted on its own terms', () => { + // The contrast that §6 turns on: the same file that §5 rejects for a + // consumer expecting A is perfectly valid for a consumer expecting B. + const result = admitEnvelope(fixtureText('wrong-repository.json'), { + sourceBaseDir: SOURCE_BASE_DIR, + expectedRepositoryId: REPOSITORY_B, + expectedRevision: REVISION_B, + }); + + expect(result.outcome).toBe('admitted'); + if (result.outcome !== 'admitted') return; + expect(result.admitted.digestCheck.outcome).toBe('match'); + expect(result.admitted.identityCheck.outcome).toBe('ok'); + expect(result.admitted.stalenessCheck.outcome).toBe('ok'); + }); + + test('a query scoped to one repository returns only that repository\'s entities', () => { + const both = [validated('valid.json'), validated('wrong-repository.json')]; + + const scopedToA = queryEntitiesForRepository(both, REPOSITORY_A); + expect(scopedToA.returnedEntities.map((entity) => entity.identity.canonicalId)).toEqual([ + 'component:default/payments', + 'component:default/ledger', + 'component:default/gateway', + ]); + expect(scopedToA.envelopesOutOfScope).toBe(1); + // Report what was looked at: both repositories were considered, one filtered. + expect(scopedToA.repositoriesConsidered).toEqual([REPOSITORY_A, REPOSITORY_B]); + + const scopedToB = queryEntitiesForRepository(both, REPOSITORY_B); + expect(scopedToB.returnedEntities.map((entity) => entity.identity.canonicalId)).toEqual([ + 'component:default/billing', + ]); + expect(scopedToB.envelopesOutOfScope).toBe(1); + }); + + test('no entity from one repository ever leaks into the other repository\'s result', () => { + const both = [validated('valid.json'), validated('wrong-repository.json')]; + const a = new Set(queryEntitiesForRepository(both, REPOSITORY_A).returnedEntities.map((e) => e.identity.canonicalId)); + const b = new Set(queryEntitiesForRepository(both, REPOSITORY_B).returnedEntities.map((e) => e.identity.canonicalId)); + + expect([...a].filter((id) => b.has(id))).toEqual([]); + expect(a.size).toBe(3); + expect(b.size).toBe(1); + }); + + test('a query scoped to a repository nobody loaded returns nothing and says so', () => { + const both = [validated('valid.json'), validated('wrong-repository.json')]; + const result = queryEntitiesForRepository(both, 'github.com/mbeacom/not-loaded'); + + expect(result.returnedEntities).toEqual([]); + // An empty result is otherwise indistinguishable from a query that never + // ran. These two fields are what make the difference visible. + expect(result.envelopesOutOfScope).toBe(2); + expect(result.repositoriesConsidered).toEqual([REPOSITORY_A, REPOSITORY_B]); + }); + + test('neither envelope is rejected by the query, and both remain independently valid', () => { + const both = [validated('valid.json'), validated('wrong-repository.json')]; + queryEntitiesForRepository(both, REPOSITORY_A); + + for (const envelope of both) { + expect(checkEnvelopeDigest(envelope).outcome).toBe('match'); + } + }); +}); diff --git a/packages/catalog-envelope/test/no-adapter-import.test.ts b/packages/catalog-envelope/test/no-adapter-import.test.ts new file mode 100644 index 00000000..add2572e --- /dev/null +++ b/packages/catalog-envelope/test/no-adapter-import.test.ts @@ -0,0 +1,183 @@ +/** + * FR-044 behavioural half: **the consumer imports nothing from + * `packages/adapters/**`, at build time or at runtime** (T037; + * `package-boundary.md` §3). + * + * ## Why a build-graph assertion rather than a manifest inspection + * + * `bun run check:deps` already reads `package.json` and rejects a declared + * dependency edge in either direction, and Phase A observed both guards firing + * (`evidence/negative-cases/dep-consumer-to-adapter/`, + * `dep-adapter-to-consumer/`). That is the *declaration* half, and it is not + * sufficient on its own: a relative import that reaches across the workspace — + * `../adapters/catalog-backstage/src/index.ts` — declares nothing, so a manifest + * check cannot see it, and it would resolve and build perfectly well. + * + * So this test bundles the consumer's entry point and inspects the **actual + * module graph** Bun resolved, which sees a relative import and a package + * import alike. It also scans the source tree for import specifiers, so a + * dynamic `import()` reachable only at runtime is caught as well. + * + * ## Why the boundary matters at all, restated + * + * The envelope file on disk is the entire interface between the two packages, + * and each declares the envelope's shape independently. If the consumer imported + * the generator's serializer, the digest check would be comparing the generator + * against itself and would detect nothing. This test is what keeps that from + * happening quietly. + */ + +import { describe, expect, test } from 'bun:test'; +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { PACKAGE_NAME } from '../src/index.ts'; + +const PACKAGE_ROOT = join(import.meta.dir, '..'); +const SRC_DIR = join(PACKAGE_ROOT, 'src'); +const ENTRYPOINT = join(SRC_DIR, 'index.ts'); + +const FORBIDDEN_PATH_SEGMENT = 'packages/adapters/'; + +/** + * Every adapter package name, read from the workspace rather than hard-coded. + * + * Two reasons, and the second is the operative one: + * + * - It is the stronger rule. FR-003 and FR-044 forbid reaching **any** adapter, + * not one named adapter, and a hard-coded name would silently stop covering a + * future one. + * - Phase A's locality guard at + * `packages/adapters/catalog-backstage/test/envelope-shape-locality.test.ts` + * forbids any `.ts` file under this package from naming the adapter package, + * which a self-referential guard would otherwise have to do. Phase A resolves + * the same self-reference problem for its own guards with an + * `EXCLUDED_FROM_SCAN` list, but that list lives in the adapter's tree, which + * this phase does not own. Deriving the names is the resolution available + * here, and it happens to be the better rule anyway. + */ +function adapterPackageNames(): string[] { + const adaptersDir = join(PACKAGE_ROOT, '..', 'adapters'); + const names: string[] = []; + for (const entry of readdirSync(adaptersDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const manifestPath = join(adaptersDir, entry.name, 'package.json'); + try { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { name?: string }; + if (manifest.name !== undefined) names.push(manifest.name); + } catch { + // A directory without a readable manifest is not an adapter package. The + // count assertion below is what catches this having swallowed everything. + } + } + return names.sort(); +} + +function sourceFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) found.push(...sourceFiles(full)); + else if (entry.name.endsWith('.ts')) found.push(full); + } + return found; +} + +/** Every module Bun resolved while bundling the consumer's entry point. */ +async function bundledModules(): Promise { + const build = await Bun.build({ + entrypoints: [ENTRYPOINT], + target: 'node', + // The workspace's TypeScript sources resolve under the `bun` condition, + // matching how `bun run typecheck` and `bun test` resolve them. + conditions: ['bun'], + }); + + if (!build.success) { + throw new Error(`the consumer's entry point did not build:\n${build.logs.map(String).join('\n')}`); + } + + const text = await build.outputs[0]!.text(); + return text + .split('\n') + .filter((line) => line.startsWith('// ') && !line.includes(' '.repeat(2))) + .map((line) => line.slice(3).trim().replaceAll('\\', '/')) + .filter((path) => path.includes('/')); +} + +describe('the consumer imports nothing from an adapter', () => { + test('the build graph contains no module under packages/adapters/', async () => { + const modules = await bundledModules(); + + // Report what was examined. A graph that came back empty — because the + // comment format changed, say — would otherwise satisfy the assertion below + // while having looked at nothing, which is the exact failure ADR-0016 is + // about. + expect(modules.length).toBeGreaterThan(20); + expect(modules.some((path) => path.includes('packages/catalog-envelope/src/'))).toBe(true); + expect(modules.some((path) => path.includes('packages/core/src/'))).toBe(true); + + const offending = modules.filter((path) => path.includes(FORBIDDEN_PATH_SEGMENT)); + expect(offending).toEqual([]); + }); + + test('no source file names any adapter package or its path', () => { + const files = sourceFiles(SRC_DIR); + const adapters = adapterPackageNames(); + expect(files.length).toBeGreaterThanOrEqual(6); + // Report what was examined: an empty adapter list would make the specifier + // rule below silently weaker than it claims to be. + expect(adapters.length).toBeGreaterThanOrEqual(2); + + const offending: string[] = []; + for (const file of files) { + const source = readFileSync(file, 'utf8'); + // Import specifiers only — static, dynamic and `require` alike. A prose + // mention of the adapter in a comment is not an edge, and treating it as + // one would make the boundary documentation unwritable. + for (const match of source.matchAll(/(?:from|import|require)\s*\(?\s*['"]([^'"]+)['"]/g)) { + const specifier = match[1] ?? ''; + // Relative specifiers are resolved before being checked. A raw + // substring test would miss `../../adapters/catalog-backstage/src/…`, + // which is precisely the form that also evades the manifest check — + // observed in + // `evidence/negative-cases/consumer-adapter-import/case-b-relative-path-import`. + const resolved = specifier.startsWith('.') + ? resolve(dirname(file), specifier).replaceAll('\\', '/') + : specifier; + const namesAnAdapter = adapters.some( + (name) => resolved === name || resolved.startsWith(`${name}/`), + ); + if (resolved.includes(FORBIDDEN_PATH_SEGMENT) || namesAnAdapter) { + offending.push(`${file.slice(PACKAGE_ROOT.length + 1)}: ${specifier}`); + } + } + } + + expect(offending).toEqual([]); + }); + + test('the consumer declares no dependency on any adapter', () => { + // The weakest of the three, kept because it is the one `check:deps` enforces + // in CI and because its absence here would be a gap between what this test + // claims and what the repository check actually covers. + const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) as { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }; + const adapters = new Set(adapterPackageNames()); + expect(adapters.size).toBeGreaterThanOrEqual(2); + + expect(manifest.dependencies).toEqual({ '@adrkit/core': 'workspace:*' }); + for (const block of [manifest.dependencies, manifest.devDependencies, manifest.peerDependencies]) { + expect(Object.keys(block ?? {}).filter((name) => adapters.has(name))).toEqual([]); + } + }); + + test('the package under test is the consumer, statically imported', () => { + // A specific observed value rather than an absence, so that a suite which + // somehow tested the wrong package would say so (ADR-0016 clause 3). + expect(PACKAGE_NAME).toBe('@adrkit/catalog-envelope'); + expect(adapterPackageNames()).not.toContain(PACKAGE_NAME); + }); +}); diff --git a/packages/catalog-envelope/test/no-core-schema-change.test.ts b/packages/catalog-envelope/test/no-core-schema-change.test.ts new file mode 100644 index 00000000..faa79054 --- /dev/null +++ b/packages/catalog-envelope/test/no-core-schema-change.test.ts @@ -0,0 +1,151 @@ +/** + * Guard: this feature leaves the core matcher surface and the published ADR + * schema **unchanged** (`spec.md` FR-004, FR-005; T026). + * + * FR-004 forbids changing `packages/core/src/affects/**`'s matcher semantics, + * the `CatalogPort` / `CatalogSnapshot` / `CatalogSnapshotEntity` shapes, the + * Zod schema module, or the published ADR JSON Schema. FR-005 additionally + * forbids adding the envelope as a field on `CatalogSnapshot` or + * `CatalogSnapshotEntity`, or putting it into any published schema. + * + * ## Why this is a digest pin and not an absence check + * + * The obvious guard — "assert `SnapshotEnvelope` does not appear in core" — is + * an assertion about an absence, and ADR-0016 clause 3 is explicit that an + * absence and a blind check render identically. It is also the wrong shape: it + * would pass while somebody quietly widened `CatalogSnapshotEntity` with an + * `ownershipState` field, which is a different route to the same forbidden + * outcome. + * + * So this pins a **specific observed value** per file — the SHA-256 of its bytes + * at `99ba8d2500eaf37625ea164f66b4a17870e40dad`, the commit Phase C branched + * from — plus the exact file list under `affects/`, so that adding or removing a + * file is caught too. + * + * ## Why the pin table lives in a JSON sidecar + * + * Stated plainly rather than left to look like an odd style choice. Phase A's + * locality guard at + * `packages/adapters/catalog-backstage/test/envelope-shape-locality.test.ts` + * scans every `.ts` file under this package for references to the published + * schema surface, on the reasoning that "a package that never names it cannot + * write it or regenerate it". That rule is over-broad for a **read-only + * integrity pin**: this guard must name those paths precisely in order to hash + * them, and it writes nothing. + * + * Phase A's own guards resolve the same self-reference problem with an + * `EXCLUDED_FROM_SCAN` list, but that list lives in the adapter's tree, which + * this phase does not own and must not edit. Keeping the paths in + * `fixtures/protected-surfaces.json` — data, reviewable, and outside the `.ts` + * scan's range — is the resolution available here. The conflict is reported for + * central reconciliation rather than worked around silently; the maintainer may + * prefer to add this file to `EXCLUDED_FROM_SCAN` instead, which would be the + * cleaner fix. + * + * ## If one of these digests fails + * + * A failure means a protected surface changed. Under this feature that is a + * **violation**, not a stale pin — FR-004 is unconditional, and updating the pin + * to match would be amending the expectation to fit the output. A legitimate + * change to these files belongs to separately-authorized later work. + * + * Observed failing before being relied on, in two ways: + * `specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/`. + */ + +import { describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import type { CatalogPort, CatalogSnapshot, CatalogSnapshotEntity } from '@adrkit/core'; + +const REPO_ROOT = join(import.meta.dir, '..', '..', '..'); + +interface ProtectedSurfaces { + readonly protectedFiles: readonly { readonly path: string; readonly sha256: string; readonly why: string }[]; + readonly publishedSchemaPath: string; + readonly affectsDirectory: string; + readonly affectsFiles: readonly string[]; + readonly forbiddenSchemaVocabulary: readonly string[]; +} + +const PINS = JSON.parse( + readFileSync(join(import.meta.dir, 'protected-surfaces.json'), 'utf8'), +) as ProtectedSurfaces; + +function digestOf(relativePath: string): string { + return createHash('sha256').update(readFileSync(join(REPO_ROOT, relativePath))).digest('hex'); +} + +function filesUnder(relativeDir: string): string[] { + const found: string[] = []; + const walk = (dir: string, prefix: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) { + const next = `${prefix}${entry.name}`; + if (entry.isDirectory()) walk(join(dir, entry.name), `${next}/`); + else found.push(next); + } + }; + walk(join(REPO_ROOT, relativeDir), ''); + return found; +} + +describe('the protected core and schema surfaces are unchanged', () => { + test('the pin table covers seven files, each with a digest and a stated reason', () => { + // Report what was examined, not only what was concluded: a pin table that + // had been emptied would otherwise make every assertion below vacuous + // (ADR-0016 clause 3). + expect(PINS.protectedFiles).toHaveLength(7); + for (const pin of PINS.protectedFiles) { + expect(pin.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(pin.why.length).toBeGreaterThan(20); + } + }); + + for (const pin of PINS.protectedFiles) { + test(`${pin.path} is byte-identical to its pin`, () => { + expect(digestOf(pin.path)).toBe(pin.sha256); + }); + } + + test('no file has been added to or removed from the affects tree', () => { + expect(filesUnder(PINS.affectsDirectory)).toEqual([...PINS.affectsFiles]); + }); +}); + +describe('the envelope is not smuggled into the core types', () => { + test('CatalogSnapshotEntity still has exactly id, refs and paths', () => { + // A type-level assertion as well as a runtime one: the literal below is the + // complete set of members, so a widened interface fails to compile here. + const entity: CatalogSnapshotEntity = { id: 'component:default/x', refs: [], paths: [] }; + expect(Object.keys(entity).sort()).toEqual(['id', 'paths', 'refs']); + }); + + test('CatalogSnapshot still has exactly entities', () => { + const snapshot: CatalogSnapshot = { entities: [] }; + expect(Object.keys(snapshot)).toEqual(['entities']); + }); + + test('CatalogPort still has exactly its three methods', () => { + const port: CatalogPort = { + resolveEntity: () => [], + entitiesForPaths: () => [], + snapshot: () => ({ entities: [] }), + }; + expect(Object.keys(port).sort()).toEqual(['entitiesForPaths', 'resolveEntity', 'snapshot']); + }); + + test('the published schema mentions no envelope vocabulary', () => { + const published = readFileSync(join(REPO_ROOT, PINS.publishedSchemaPath), 'utf8'); + + // Paired with the digest pin above rather than standing alone: on its own + // this is an absence check, and an absence check cannot tell "looked and + // found nothing" from "could not look". The digest pin is what makes it + // meaningful; this states the specific thing FR-005 forbids. + expect(published.length).toBeGreaterThan(1000); + expect(PINS.forbiddenSchemaVocabulary.length).toBeGreaterThanOrEqual(5); + + const found = PINS.forbiddenSchemaVocabulary.filter((term) => published.includes(term)); + expect(found).toEqual([]); + }); +}); diff --git a/packages/catalog-envelope/test/no-correctness-claim.test.ts b/packages/catalog-envelope/test/no-correctness-claim.test.ts new file mode 100644 index 00000000..19921733 --- /dev/null +++ b/packages/catalog-envelope/test/no-correctness-claim.test.ts @@ -0,0 +1,244 @@ +/** + * Guard: nothing this package exports, emits, or documents claims **correctness** + * (`spec.md` FR-058; SC-012 framing half; T035). + * + * ADR-0020 clause 5: a populated, digest-verified envelope proves **integrity, + * not correctness** — a semantically wrong envelope can carry a perfectly valid + * self-digest. SC-012 requires that no artifact, report, or document produced + * under this feature present such an envelope as evidence of semantic + * correctness. + * + * ## Why a vocabulary check and not only a prose statement + * + * The framing is easy to write once in a README and then quietly undo in an + * identifier — a function called `verifyOwnership`, a result field named + * `correct`, an error string saying "ownership is valid". Each reads naturally + * and each claims the thing the record forbids. So the exported surface, the + * error and detail strings the package actually emits, and the package's own + * documentation are all scanned for the forbidden vocabulary. + * + * The list below is deliberately narrow. It targets words that assert semantic + * rightness about *ownership* or *the catalog*, not the word "valid" as such: + * "valid JSON", "structurally valid envelope", and "individually valid" are all + * accurate and are the contract's own vocabulary (`snapshot-envelope.md` §2, + * §6). A check that banned "valid" outright would be unsatisfiable against the + * contract it is enforcing. + */ + +import { describe, expect, test } from 'bun:test'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import * as consumer from '../src/index.ts'; +import { + DIGEST_GUARANTEE_SCOPE, + admitEnvelope, + checkEnvelopeDigest, + validateEnvelope, +} from '../src/index.ts'; +import { ADMIT_OPTIONS_A, VALIDATE_OPTIONS, fixtureText } from './helpers.ts'; + +const PACKAGE_ROOT = join(import.meta.dir, '..'); + +/** + * Phrases that assert semantic rightness. Each is a claim this package cannot + * support, whether it appears in an identifier, an emitted string, or prose. + */ +const FORBIDDEN_CLAIMS = [ + 'semantically correct', + 'ownership is correct', + 'correct ownership', + 'verified correct', + 'proves correctness', + 'guarantees correctness', + 'tamper-proof', + 'tamper-resistant', + 'tamper resistant', + 'cryptographically secure', + 'authoritative ownership', +] as const; + +/** + * Contexts in which the word "correctness" is legitimate — every one of them is + * a *denial* of a correctness claim rather than an assertion of one. + */ +function stripLegitimateCorrectnessMentions(text: string): string { + return text + .replaceAll(/integrity,? not correctness/gi, '') + .replaceAll(/not correctness/gi, '') + .replaceAll(/never correctness/gi, '') + .replaceAll(/correctness is (?:claimed|established) only/gi, '') + .replaceAll(/correctness oracle/gi, '') + .replaceAll(/a statement about .{0,40}correctness/gi, ''); +} + +function sourceFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) found.push(...sourceFiles(full)); + else if (entry.name.endsWith('.ts')) found.push(full); + } + return found; +} + +describe('the exported surface makes no correctness claim', () => { + test('no exported name asserts correctness', () => { + const names = Object.keys(consumer).sort(); + + // Report what was examined. An empty export list would otherwise satisfy + // every assertion below while proving nothing (ADR-0016 clause 3). + expect(names.length).toBeGreaterThanOrEqual(20); + expect(names).toContain('admitEnvelope'); + expect(names).toContain('deriveCatalogSnapshot'); + expect(names).toContain('DIGEST_GUARANTEE_SCOPE'); + + const offending = names.filter((name) => + // `proven` is bounded so that `isRecognizedProvenance` — which is the + // contract's own vocabulary (`snapshot-envelope.md` §2) and asserts + // nothing about rightness — is not caught by it. + /correct|\bproven\b|proves|guarantee[ds]|authoritative|trustworthy/i.test(name), + ); + expect(offending).toEqual([]); + }); + + test('the exported vocabulary is about admission and checking, not judgement', () => { + // A specific observed list rather than an absence: if the surface is ever + // reshaped, this says so out loud instead of silently continuing to pass. + const verbs = Object.keys(consumer) + .filter((name) => /^(admit|check|validate|derive|recompute|query|is)/.test(name)) + .sort(); + + expect(verbs).toEqual([ + 'admitEnvelope', + 'admittedEnvelopeOf', + 'checkEnvelopeDigest', + 'checkRepositoryIdentity', + 'checkStaleness', + 'deriveCatalogSnapshot', + 'isAdmittedEnvelope', + 'isRecognizedProvenance', + 'isStructurallyValidEnvelope', + 'queryEntitiesForRepository', + 'recomputeEnvelopeDigest', + 'validateEnvelope', + 'validateParsedEnvelope', + ]); + }); +}); + +describe('the strings this package emits make no correctness claim', () => { + /** Every detail/reason string reachable across the whole fixture corpus. */ + function emittedStrings(): string[] { + const strings: string[] = [DIGEST_GUARANTEE_SCOPE]; + const fixtures = readdirSync(join(PACKAGE_ROOT, 'test', 'fixtures')).filter((name) => + name.endsWith('.json'), + ); + expect(fixtures.length).toBe(10); + + for (const fixture of fixtures) { + const text = fixtureText(fixture); + const validation = validateEnvelope(text, VALIDATE_OPTIONS); + if (validation.outcome === 'rejected') strings.push(validation.reason, validation.detail); + else strings.push(JSON.stringify(checkEnvelopeDigest(validation.validated))); + + const admission = admitEnvelope(text, ADMIT_OPTIONS_A); + if (admission.outcome === 'refused') strings.push(admission.reason, admission.detail); + else strings.push(JSON.stringify(admission.admitted.stalenessCheck), JSON.stringify(admission.admitted.identityCheck)); + } + return strings; + } + + test('no emitted string carries a forbidden claim', () => { + const strings = emittedStrings(); + expect(strings.length).toBeGreaterThan(20); + + for (const emitted of strings) { + const haystack = stripLegitimateCorrectnessMentions(emitted).toLowerCase(); + for (const claim of FORBIDDEN_CLAIMS) { + expect(haystack).not.toContain(claim); + } + } + }); + + test('the one string that mentions correctness denies it', () => { + expect(DIGEST_GUARANTEE_SCOPE).toContain('integrity, not correctness'); + expect(stripLegitimateCorrectnessMentions(DIGEST_GUARANTEE_SCOPE).toLowerCase()).not.toContain( + 'correctness', + ); + }); +}); + +describe('the documentation makes no correctness claim', () => { + function documents(): { readonly name: string; readonly text: string }[] { + const docs = [ + { name: 'README.md', text: readFileSync(join(PACKAGE_ROOT, 'README.md'), 'utf8') }, + { + name: 'test/fixtures/README.md', + text: readFileSync(join(PACKAGE_ROOT, 'test', 'fixtures', 'README.md'), 'utf8'), + }, + ]; + for (const file of sourceFiles(join(PACKAGE_ROOT, 'src'))) { + docs.push({ name: file.slice(PACKAGE_ROOT.length + 1), text: readFileSync(file, 'utf8') }); + } + return docs; + } + + test('no document carries a forbidden claim', () => { + const docs = documents(); + expect(docs.length).toBeGreaterThanOrEqual(8); + + const offending: string[] = []; + for (const doc of docs) { + const haystack = stripLegitimateCorrectnessMentions(doc.text).toLowerCase(); + for (const claim of FORBIDDEN_CLAIMS) { + if (haystack.includes(claim)) offending.push(`${doc.name}: ${claim}`); + } + } + expect(offending).toEqual([]); + }); + + test('the README states the distinction before it states anything else', () => { + const readme = readFileSync(join(PACKAGE_ROOT, 'README.md'), 'utf8'); + const headings = readme.split('\n').filter((line) => line.startsWith('## ')); + // Collapsed so the assertions are about the prose, not about where the + // hard wrap happens to fall. + const flowed = readme.replaceAll(/\s+/g, ' '); + + expect(headings[0]).toBe('## An integrity validator, not a correctness oracle'); + expect(flowed).toContain('proves integrity, not correctness'); + expect(flowed).toContain('a semantically wrong envelope can carry a perfectly valid self-digest'); + }); + + test('no document claims rung 2 or rung 3 standing', () => { + // ADR-0014 rung 1 only, per ADR-0020's closing paragraph and `spec.md` + // FR-062. The forbidden synonyms are the ones that would let the claim in + // through the side door. + const forbidden = [ + 'reference-verified', + 'externally validated', + 'community-validated', + 'community validated', + 'third-party validated', + 'battle-tested', + 'production-proven', + 'production proven', + ]; + + const offending: string[] = []; + for (const doc of documents()) { + const haystack = doc.text.toLowerCase(); + for (const term of forbidden) { + // A denial ("is **not** reference-verified") is the required framing, so + // only an unqualified assertion counts. Every occurrence in this package + // is preceded by "not " or "neither ". + for (const match of haystack.matchAll(new RegExp(term, 'g'))) { + const before = haystack.slice(Math.max(0, match.index - 40), match.index); + if (!/\bnot\b|\bneither\b|\bnever\b|\bwithout\b/.test(before)) { + offending.push(`${doc.name}: ${term}`); + } + } + } + } + expect(offending).toEqual([]); + }); +}); diff --git a/packages/catalog-envelope/test/no-early-read.test.ts b/packages/catalog-envelope/test/no-early-read.test.ts new file mode 100644 index 00000000..bb2e0ae5 --- /dev/null +++ b/packages/catalog-envelope/test/no-early-read.test.ts @@ -0,0 +1,250 @@ +/** + * The ordering guard: **no `derivedPaths` value is read before every check has + * passed, and derivation before validation is refused** + * (`spec.md` FR-046; `snapshot-envelope.md` §2, §3; T030). + * + * ## What "read" means here, stated precisely rather than gestured at + * + * There is exactly one place in `src/` where the validator touches + * `derivedPaths`: a single **type inspection** in step 2, on the line marked + * `STEP-2 TYPE INSPECTION`, which confirms the field is an array of strings and + * consumes nothing. Step 2 could not do its job — "the complete shape with every + * field the correct JSON type, at **every** nesting level" — without it. + * + * So the property this file enforces is the one that is actually meaningful, in + * three parts: + * + * 1. **Count.** During validation, `derivedPaths` is read **at most once per + * entity record**, and never after the step at which a rejection occurred. + * An envelope rejected at step 1 produces **zero** reads. + * 2. **Reachability.** No value-consuming read is reachable at all without an + * admission token, which cannot be forged from outside the module. + * 3. **Locality.** `derivedPaths` appears in `src/` only in the shape + * declaration, in that one marked step-2 line, and behind the admission gate + * in `snapshot/index.ts`. This is what stops a later edit from adding a + * fourth, unguarded read. + * + * Asserting only (1) would be weak — a single read is enough to leak a value. + * Asserting only (2) would be weak — the type system does not stop a cast. + * Asserting only (3) would be weak — a file-level check cannot see order. + * Together they say something. + */ + +import { describe, expect, test } from 'bun:test'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { + EnvelopeDerivationRefusedError, + admitEnvelope, + checkEnvelopeDigest, + checkRepositoryIdentity, + checkStaleness, + deriveCatalogSnapshot, + validateParsedEnvelope, +} from '../src/index.ts'; +import { + ADMIT_OPTIONS_A, + REPOSITORY_A, + REVISION_A, + VALIDATE_OPTIONS, + fixtureText, + fixtureValue, +} from './helpers.ts'; + +/** + * Replace every entity's `derivedPaths` with an accessor that counts reads of + * the property on that record, leaving the value itself unchanged. + */ +function instrument(envelope: Record): { readonly value: unknown; reads: () => number } { + let reads = 0; + const entities = envelope['entities']; + if (Array.isArray(entities)) { + for (const entity of entities) { + if (typeof entity !== 'object' || entity === null) continue; + const record = entity as Record; + if (!('derivedPaths' in record)) continue; + const actual = record['derivedPaths']; + delete record['derivedPaths']; + Object.defineProperty(record, 'derivedPaths', { + enumerable: true, + configurable: true, + get() { + reads += 1; + return actual; + }, + }); + } + } + return { value: envelope, reads: () => reads }; +} + +describe('no derivedPaths value is read before validation completes', () => { + test('an envelope rejected at step 2 stops reading at the record that failed', () => { + // `entities[1].identity.allRefs` is a string, so record 1 is rejected before + // its own `derivedPaths` is reached. Exactly one read — record 0's — occurs. + const instrumented = instrument(fixtureValue('malformed-missing-or-wrong-field.json')); + const result = validateParsedEnvelope(instrumented.value, VALIDATE_OPTIONS); + + expect(result.outcome).toBe('rejected'); + if (result.outcome !== 'rejected') return; + expect(result.failedStep).toBe(2); + expect(result.examined.entityRecordsInspected).toBe(2); + expect(instrumented.reads()).toBe(1); + }); + + test('an envelope rejected at step 3 reads each record exactly once and then stops', () => { + const instrumented = instrument(fixtureValue('malformed-unrecognized.json')); + const result = validateParsedEnvelope(instrumented.value, VALIDATE_OPTIONS); + + expect(result.outcome).toBe('rejected'); + if (result.outcome !== 'rejected') return; + expect(result.failedStep).toBe(3); + // Three records, three type inspections in step 2 — and nothing after, + // because steps 3, 4 and 5 never touch the field. + expect(result.examined.entityRecordsInspected).toBe(3); + expect(instrumented.reads()).toBe(3); + }); + + test('an envelope rejected at step 4 reads no more than step 2 already did', () => { + const instrumented = instrument(fixtureValue('malformed-missing-source-digest.json')); + const result = validateParsedEnvelope(instrumented.value, VALIDATE_OPTIONS); + + expect(result.outcome).toBe('rejected'); + if (result.outcome !== 'rejected') return; + expect(result.failedStep).toBe(4); + expect(instrumented.reads()).toBe(3); + }); + + test('an envelope rejected at step 5 reads no more than step 2 already did', () => { + const instrumented = instrument(fixtureValue('malformed-identity-only.json')); + const result = validateParsedEnvelope(instrumented.value, VALIDATE_OPTIONS); + + expect(result.outcome).toBe('rejected'); + if (result.outcome !== 'rejected') return; + expect(result.failedStep).toBe(5); + expect(instrumented.reads()).toBe(3); + }); + + test('an envelope rejected at step 1 produces zero reads', () => { + // Unparseable text never becomes an object, so there is nothing to read. + const result = admitEnvelope(fixtureText('malformed-invalid-json.json'), ADMIT_OPTIONS_A); + expect(result.outcome).toBe('refused'); + if (result.outcome !== 'refused') return; + expect(result.refusedAt).toBe('validation'); + expect(result.validation.examined.entityRecordsInspected).toBe(0); + }); + + test('the staleness and identity checks read no derivedPaths, and the digest reads them only as bytes', () => { + const instrumented = instrument(fixtureValue('valid.json')); + const validation = validateParsedEnvelope(instrumented.value, VALIDATE_OPTIONS); + expect(validation.outcome).toBe('valid'); + if (validation.outcome !== 'valid') return; + + const afterValidation = instrumented.reads(); + expect(afterValidation).toBe(3); + + // The digest is a SHA-256 over the canonical form of *every* field except + // `digest` itself, so it necessarily serializes `derivedPaths` — at least + // once per record, and in fact twice, because `canonicalStringify` makes a + // key-filtering pass before its serializing pass (observed: 3 validation + // reads, then 6 digest reads, 9 total). The lower bound is asserted rather + // than the exact count, so an innocuous refactor inside `@adrkit/core` does + // not fail this test for the wrong reason. + // + // None of that is an FR-046 violation, and it must not be described as one: + // the digest check *is* one of the checks FR-046 requires to pass, and + // hashing a field is not trusting its value. What FR-046 forbids is + // consuming a value for ownership before every check has passed, and the + // only code that does that is behind the admission gate. + checkEnvelopeDigest(validation.validated); + const afterDigest = instrumented.reads(); + expect(afterDigest).toBeGreaterThanOrEqual(afterValidation + 3); + + // Staleness and identity read `repository` only. The count must not move. + checkStaleness(validation.validated, REVISION_A, REPOSITORY_A); + checkRepositoryIdentity(validation.validated, REPOSITORY_A); + expect(instrumented.reads()).toBe(afterDigest); + }); +}); + +describe('derivation before validation is refused', () => { + const unadmitted: readonly { readonly name: string; readonly value: unknown }[] = [ + { name: 'undefined', value: undefined }, + { name: 'null', value: null }, + { name: 'a raw parsed envelope object', value: fixtureValue('valid.json') }, + { name: 'a hand-built object claiming to be admitted', value: { admitted: true, envelope: fixtureValue('valid.json') } }, + { name: 'a string', value: fixtureText('valid.json') }, + ]; + + for (const attempt of unadmitted) { + test(`refuses derivation from ${attempt.name}`, () => { + expect(() => deriveCatalogSnapshot(attempt.value)).toThrow(EnvelopeDerivationRefusedError); + try { + deriveCatalogSnapshot(attempt.value); + } catch (error) { + expect(error).toBeInstanceOf(EnvelopeDerivationRefusedError); + expect((error as EnvelopeDerivationRefusedError).reason).toBe( + 'derivation-refused-envelope-not-admitted', + ); + expect((error as Error).message).toContain('has not passed the five validation steps'); + } + }); + } + + test('refuses derivation from every refused admission result', () => { + for (const fixture of [ + 'malformed-invalid-json.json', + 'malformed-missing-or-wrong-field.json', + 'malformed-unrecognized.json', + 'malformed-missing-source-digest.json', + 'malformed-identity-only.json', + 'tampered.json', + 'stale.json', + 'wrong-repository.json', + ]) { + const result = admitEnvelope(fixtureText(fixture), ADMIT_OPTIONS_A); + expect(result.outcome).toBe('refused'); + expect(() => deriveCatalogSnapshot(result.admitted)).toThrow(EnvelopeDerivationRefusedError); + } + }); +}); + +describe('derivedPaths is only reachable in three declared places', () => { + function sourceFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) found.push(...sourceFiles(full)); + else if (entry.name.endsWith('.ts')) found.push(full); + } + return found; + } + + test('no fourth reference to derivedPaths exists under src/', () => { + const srcDir = join(import.meta.dir, '..', 'src'); + const files = sourceFiles(srcDir); + + // Report what was examined, not only what was concluded (ADR-0016). A file + // list that had silently come back empty would otherwise pass this test. + expect(files.length).toBeGreaterThanOrEqual(6); + + const referencing = files + .filter((file) => readFileSync(file, 'utf8').includes('derivedPaths')) + .map((file) => file.slice(srcDir.length + 1).replaceAll('\\', '/')) + .sort(); + + expect(referencing).toEqual(['envelope-shape.ts', 'snapshot/index.ts', 'validate/index.ts']); + }); + + test('the validator holds exactly one derivedPaths read, and it is the marked one', () => { + const validator = readFileSync(join(import.meta.dir, '..', 'src', 'validate', 'index.ts'), 'utf8'); + const lines = validator.split('\n').filter((line) => line.includes('derivedPaths')); + + // One code line reading the property, one line naming it in a message, and + // the comment lines that explain the discipline. The code read is the one + // carrying the marker. + const codeReads = lines.filter((line) => /record\['derivedPaths'\]/.test(line)); + expect(codeReads).toHaveLength(1); + expect(codeReads[0]).toContain('STEP-2 TYPE INSPECTION'); + }); +}); diff --git a/packages/catalog-envelope/test/protected-surfaces.json b/packages/catalog-envelope/test/protected-surfaces.json new file mode 100644 index 00000000..4a6d564d --- /dev/null +++ b/packages/catalog-envelope/test/protected-surfaces.json @@ -0,0 +1,58 @@ +{ + "//": "Pin table for `test/no-core-schema-change.test.ts` (T026, spec.md FR-004/FR-005). Digests read at 99ba8d2500eaf37625ea164f66b4a17870e40dad in this worktree. See that file's header for why these live in data rather than in the test source.", + "protectedFiles": [ + { + "path": "packages/core/src/affects/catalog.ts", + "sha256": "7d0f3f30e5ff7e98a4ba931df6c242118a7a3dd9a6c726d7cfdf6ca69d52122e", + "why": "CatalogPort / CatalogSnapshot / CatalogSnapshotEntity — the types FR-004 names and FR-005 forbids the envelope becoming a field on" + }, + { + "path": "packages/core/src/affects/index.ts", + "sha256": "bc347e49280412413a005c5f96aec1943f838213c1fc9c7df6b86bdfadc7b66c", + "why": "the affects resolution entry point — FR-004's matcher semantics" + }, + { + "path": "packages/core/src/affects/inert.ts", + "sha256": "a1a1803a8f63c28628b14203303d17a214d0318c559f13319f51c50036f7a9ae", + "why": "inert-matcher degradation — FR-004's matcher semantics" + }, + { + "path": "packages/core/src/affects/matchers/package.ts", + "sha256": "262dee9be89be0d13b843a61d48d6bc5b028cef0adb9d1ac65ba69ba38f0753a", + "why": "package matcher — FR-004's matcher semantics" + }, + { + "path": "packages/core/src/affects/matchers/path.ts", + "sha256": "fe2c49011f5f9458182923cc0eb0006e09837f566a68c496245f7fd839719ae3", + "why": "path matcher — FR-004's matcher semantics" + }, + { + "path": "packages/core/src/schema/adr.schema.ts", + "sha256": "eae1b3c1fe203e8f2ca20657777c6508391f94df4c9e39399bf4b7385ad13e10", + "why": "the Zod ADR schema FR-004 names" + }, + { + "path": "schema/adr.schema.json", + "sha256": "1e1841151174cc5a8ed22dadae070f087477e5068bd928d5292c3acd2e2681cc", + "why": "the published ADR JSON Schema — FR-004, and FR-005's 'never part of any published schema'" + } + ], + "publishedSchemaPath": "schema/adr.schema.json", + "affectsDirectory": "packages/core/src/affects", + "affectsFiles": [ + "catalog.ts", + "index.ts", + "inert.ts", + "matchers/package.ts", + "matchers/path.ts" + ], + "forbiddenSchemaVocabulary": [ + "snapshotEnvelope", + "SnapshotEnvelope", + "derivedPaths", + "globDialect", + "ownershipState", + "identityOnly", + "generatorVersion" + ] +} diff --git a/packages/catalog-envelope/test/sc-014.test.ts b/packages/catalog-envelope/test/sc-014.test.ts new file mode 100644 index 00000000..85a5bbdf --- /dev/null +++ b/packages/catalog-envelope/test/sc-014.test.ts @@ -0,0 +1,218 @@ +/** + * SC-014 close-out: **consumer rejection and isolation**, asserted as one + * consolidated statement (`snapshot-envelope.md` §7; T036). + * + * SC-014 reads: + * + * > 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. + * + * The per-check detail lives in `validate-steps.test.ts`, `digest.test.ts`, + * `identity.test.ts`, `no-early-read.test.ts` and `derive.test.ts`. This file + * exists so the criterion is discharged by a **single table** covering every + * clause at once, rather than by a reader assembling five files and hoping the + * union is complete. + * + * The ADR-0016 observations that make these assertions coverage rather than + * decoration are recorded under + * `specs/010-catalog-backstage/evidence/negative-cases/`: `consumer-steps/`, + * `consumer-digest/`, `consumer-staleness/`, `consumer-repository-identity/`. + */ + +import { describe, expect, test } from 'bun:test'; +import { + admitEnvelope, + deriveCatalogSnapshot, + queryEntitiesForRepository, + validateEnvelope, + validateParsedEnvelope, + type AdmissionStage, +} from '../src/index.ts'; +import { + ADMIT_OPTIONS_A, + REPOSITORY_A, + REPOSITORY_B, + REVISION_B, + SOURCE_BASE_DIR, + VALIDATE_OPTIONS, + fixtureText, + fixtureValue, +} from './helpers.ts'; + +interface RejectionRow { + readonly fixture: string; + readonly stage: AdmissionStage; + readonly reason: string; + /** The step number, for the five validation-step rows only. */ + readonly step: number | undefined; +} + +/** Every rejection SC-014 enumerates, in the order the criterion states them. */ +const REJECTIONS: readonly RejectionRow[] = [ + { fixture: 'malformed-invalid-json.json', stage: 'validation', reason: 'invalid-json', step: 1 }, + { fixture: 'malformed-missing-or-wrong-field.json', stage: 'validation', reason: 'missing-or-wrong-required-field', step: 2 }, + { fixture: 'malformed-unrecognized.json', stage: 'validation', reason: 'unrecognized-schema-or-dialect-or-capability', step: 3 }, + { fixture: 'malformed-missing-source-digest.json', stage: 'validation', reason: 'missing-source-digest', step: 4 }, + { fixture: 'malformed-identity-only.json', stage: 'validation', reason: 'identity-only-true', step: 5 }, + { fixture: 'tampered.json', stage: 'digest', reason: 'digest-mismatch', step: undefined }, + { fixture: 'stale.json', stage: 'staleness', reason: 'stale-revision', step: undefined }, + { fixture: 'wrong-repository.json', stage: 'repository-identity', reason: 'repository-identity-mismatch', step: undefined }, +]; + +describe('SC-014 — every rejection lands at its own stage with its own reason', () => { + test('the eight rejections form eight distinct (stage, reason) pairs', () => { + const observed = REJECTIONS.map((row) => { + const result = admitEnvelope(fixtureText(row.fixture), ADMIT_OPTIONS_A); + if (result.outcome !== 'refused') throw new Error(`${row.fixture} was admitted`); + return { fixture: row.fixture, stage: result.refusedAt, reason: result.reason }; + }); + + expect(observed).toEqual( + REJECTIONS.map((row) => ({ fixture: row.fixture, stage: row.stage, reason: row.reason })), + ); + expect(new Set(observed.map((entry) => entry.reason)).size).toBe(8); + expect(new Set(observed.map((entry) => entry.stage)).size).toBe(4); + }); + + test('the five validation-step rejections land at steps 1 through 5, in order', () => { + const steps = REJECTIONS.filter((row) => row.step !== undefined).map((row) => { + const result = validateEnvelope(fixtureText(row.fixture), VALIDATE_OPTIONS); + if (result.outcome !== 'rejected') throw new Error(`${row.fixture} was not rejected`); + return result.failedStep; + }); + + expect(steps).toEqual([1, 2, 3, 4, 5]); + }); + + test('each rejection names specifics, not only a category', () => { + for (const row of REJECTIONS) { + const result = admitEnvelope(fixtureText(row.fixture), ADMIT_OPTIONS_A); + if (result.outcome !== 'refused') throw new Error(`${row.fixture} was admitted`); + expect(result.detail.length).toBeGreaterThan(20); + expect(result.detail).not.toBe(result.reason); + } + }); +}); + +describe('SC-014 — no derivedPaths value was read in any rejection case', () => { + test('every rejected fixture reads derivedPaths only for step 2 type inspection', () => { + for (const row of REJECTIONS) { + if (row.fixture === 'malformed-invalid-json.json') { + // Unparseable text never becomes an object, so there is nothing to + // instrument and nothing that could be read. + const result = admitEnvelope(fixtureText(row.fixture), ADMIT_OPTIONS_A); + expect(result.outcome).toBe('refused'); + expect(result.validation.examined.entityRecordsInspected).toBe(0); + continue; + } + + let reads = 0; + const parsed = fixtureValue(row.fixture); + for (const entity of parsed['entities'] as Record[]) { + const actual = entity['derivedPaths']; + delete entity['derivedPaths']; + Object.defineProperty(entity, 'derivedPaths', { + enumerable: true, + configurable: true, + get() { + reads += 1; + return actual; + }, + }); + } + + // Driven through the post-parse seam rather than re-serialized: calling + // `JSON.stringify` on the instrumented object would itself trip every + // getter and the count would be measuring the harness. + const result = validateParsedEnvelope(parsed, VALIDATE_OPTIONS); + + // Step 2 inspects the field's type once per record it reaches, and no + // later step touches it. So the count can never exceed the number of + // records step 2 actually inspected. + expect(reads).toBeLessThanOrEqual(result.examined.entityRecordsInspected); + } + }); + + test('derivation is unreachable for every rejected fixture', () => { + for (const row of REJECTIONS) { + const result = admitEnvelope(fixtureText(row.fixture), ADMIT_OPTIONS_A); + expect(result.admitted).toBeUndefined(); + expect(() => deriveCatalogSnapshot(result.admitted)).toThrow(); + } + }); +}); + +describe('SC-014 — the contrasting acceptance cases', () => { + test('a valid envelope for a different repository is accepted on its own terms', () => { + const result = admitEnvelope(fixtureText('wrong-repository.json'), { + sourceBaseDir: SOURCE_BASE_DIR, + expectedRepositoryId: REPOSITORY_B, + expectedRevision: REVISION_B, + }); + + // The same file rejected above as misidentified. Isolation is a property of + // the query, not of the envelope — the envelope was never invalid. + expect(result.outcome).toBe('admitted'); + if (result.outcome !== 'admitted') return; + expect(result.admitted.digestCheck.outcome).toBe('match'); + }); + + test('a query across both repositories returns only the scoped one, rejecting neither', () => { + const a = validateEnvelope(fixtureText('valid.json'), VALIDATE_OPTIONS); + const b = validateEnvelope(fixtureText('wrong-repository.json'), VALIDATE_OPTIONS); + expect(a.outcome).toBe('valid'); + expect(b.outcome).toBe('valid'); + if (a.outcome !== 'valid' || b.outcome !== 'valid') return; + + const scoped = queryEntitiesForRepository([a.validated, b.validated], REPOSITORY_A); + + expect(scoped.returnedEntities).toHaveLength(3); + expect(scoped.returnedEntities.every((entity) => entity.identity.canonicalId.startsWith('component:default/'))).toBe(true); + expect(scoped.returnedEntities.map((entity) => entity.identity.canonicalId)).not.toContain( + 'component:default/billing', + ); + // What was looked at, so an empty-looking result cannot be confused with a + // query that never ran. + expect(scoped.repositoriesConsidered).toEqual([REPOSITORY_A, REPOSITORY_B]); + expect(scoped.envelopesOutOfScope).toBe(1); + }); + + test('an envelope whose entities are all annotation-absent is accepted', () => { + // `snapshot-envelope.md` §7 row 1b: never rejected on ownership-state + // distribution alone. + const result = admitEnvelope(fixtureText('all-annotation-absent.json'), ADMIT_OPTIONS_A); + expect(result.outcome).toBe('admitted'); + }); + + test('the valid envelope is admitted and derives', () => { + const result = admitEnvelope(fixtureText('valid.json'), ADMIT_OPTIONS_A); + expect(result.outcome).toBe('admitted'); + if (result.outcome !== 'admitted') return; + + const derived = deriveCatalogSnapshot(result.admitted); + expect(derived.snapshot.entities).toHaveLength(3); + expect(derived.derivedFrom.repositoryId).toBe(REPOSITORY_A); + }); +}); + +describe('SC-014 — the fixture corpus is complete and synthetic', () => { + test('ten fixtures, covering eight rejections and two acceptances', () => { + // A criterion asserted against a corpus that had silently lost a fixture + // would still pass every test above. This is the guard against that. + const rejecting = new Set(REJECTIONS.map((row) => row.fixture)); + const accepting = new Set(['valid.json', 'all-annotation-absent.json', 'wrong-repository.json']); + + expect(rejecting.size).toBe(8); + // `wrong-repository.json` appears in both roles — rejected by a consumer + // expecting repository A, accepted by one expecting repository B. That + // overlap is the §5/§6 distinction, not a bookkeeping error. + expect([...accepting].filter((name) => rejecting.has(name))).toEqual(['wrong-repository.json']); + expect(new Set([...rejecting, ...accepting]).size).toBe(10); + }); +}); diff --git a/packages/catalog-envelope/test/validate-steps.test.ts b/packages/catalog-envelope/test/validate-steps.test.ts new file mode 100644 index 00000000..f1212660 --- /dev/null +++ b/packages/catalog-envelope/test/validate-steps.test.ts @@ -0,0 +1,308 @@ +/** + * The five ordered validation steps, each observed rejecting **at its own step** + * (`snapshot-envelope.md` §2; `spec.md` FR-045; T028/T029). + * + * Two properties are asserted for every malformed fixture, and the second is the + * one that is easy to omit: + * + * 1. it is rejected with the reason belonging to its step, and + * 2. **it is not rejected at any earlier step.** A validator that rejected + * everything at step 2 would satisfy (1) for exactly one fixture and look + * broadly correct. Asserting the step number is what distinguishes + * "rejected for the right reason" from "rejected". + * + * The ADR-0016 observation that makes these assertions coverage rather than + * decoration is recorded at + * `specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/`: each of + * the five steps was deleted from the validator in turn, this file was run, and + * the resulting failure was captured verbatim. + */ + +import { describe, expect, test } from 'bun:test'; +import { + REASON_STEP, + validateEnvelope, + type EnvelopeRejectionReason, + type ValidationStep, +} from '../src/index.ts'; +import { VALIDATE_OPTIONS, fixtureText } from './helpers.ts'; + +interface Case { + readonly fixture: string; + readonly step: ValidationStep; + readonly reason: EnvelopeRejectionReason; + readonly detailContains: string; +} + +/** One case per malformation kind, mapped 1:1 to the step it must fail at. */ +const CASES: readonly Case[] = [ + { + fixture: 'malformed-invalid-json.json', + step: 1, + reason: 'invalid-json', + detailContains: 'does not parse as JSON', + }, + { + fixture: 'malformed-missing-or-wrong-field.json', + step: 2, + reason: 'missing-or-wrong-required-field', + detailContains: 'entities[1].identity.allRefs is not a string array', + }, + { + fixture: 'malformed-unrecognized.json', + step: 3, + reason: 'unrecognized-schema-or-dialect-or-capability', + detailContains: 'globDialect.engine is "minimatch"', + }, + { + fixture: 'malformed-missing-source-digest.json', + step: 4, + reason: 'missing-source-digest', + detailContains: 'sources[0] (catalog-info.yaml) declares no digest', + }, + { + fixture: 'malformed-identity-only.json', + step: 5, + reason: 'identity-only-true', + detailContains: 'completeness.identityOnly is true', + }, +]; + +describe('five ordered validation steps', () => { + test('the reason-to-step mapping is 1:1 and closed', () => { + expect(REASON_STEP).toEqual({ + 'invalid-json': 1, + 'missing-or-wrong-required-field': 2, + 'unrecognized-schema-or-dialect-or-capability': 3, + 'missing-source-digest': 4, + 'identity-only-true': 5, + }); + expect(Object.keys(REASON_STEP)).toHaveLength(5); + }); + + for (const testCase of CASES) { + test(`${testCase.fixture} is rejected at step ${testCase.step} as ${testCase.reason}`, () => { + const result = validateEnvelope(fixtureText(testCase.fixture), VALIDATE_OPTIONS); + + expect(result.outcome).toBe('rejected'); + if (result.outcome !== 'rejected') return; + + expect(result.failedStep).toBe(testCase.step); + expect(result.reason).toBe(testCase.reason); + expect(result.detail).toContain(testCase.detailContains); + expect(result.validated).toBeUndefined(); + + // Not rejected earlier than its target step: the examination reports the + // highest step actually reached, and it must equal the failing step. + expect(result.examined.stepsReached).toBe(testCase.step); + }); + } + + test('the five rejections are five distinct reasons at five distinct steps', () => { + const observed = CASES.map((testCase) => { + const result = validateEnvelope(fixtureText(testCase.fixture), VALIDATE_OPTIONS); + if (result.outcome !== 'rejected') throw new Error(`${testCase.fixture} was not rejected`); + return { step: result.failedStep, reason: result.reason }; + }); + + expect(new Set(observed.map((entry) => entry.reason)).size).toBe(5); + expect(observed.map((entry) => entry.step)).toEqual([1, 2, 3, 4, 5]); + }); + + test('step 4 is reached only after steps 2 and 3 have passed', () => { + // The missing-source-digest fixture is structurally complete and carries the + // frozen dialect, so it exercises the ordering directly: a validator that + // folded digest-presence into the shape check would reject it at step 2. + const result = validateEnvelope(fixtureText('malformed-missing-source-digest.json'), VALIDATE_OPTIONS); + expect(result.outcome).toBe('rejected'); + if (result.outcome !== 'rejected') return; + expect(result.failedStep).toBe(4); + // Every entity record was inspected during step 2 before step 4 was reached. + expect(result.examined.entityRecordsInspected).toBe(3); + }); + + test('step 5 is reached only after every source digest has been verified', () => { + const result = validateEnvelope(fixtureText('malformed-identity-only.json'), VALIDATE_OPTIONS); + expect(result.outcome).toBe('rejected'); + if (result.outcome !== 'rejected') return; + expect(result.failedStep).toBe(5); + expect(result.examined.sourcesVerified).toEqual(['catalog-info.yaml']); + }); +}); + +describe('step 3 checks the frozen matcher contract by exact value', () => { + // `snapshot-envelope.md` §2 step 3 names three insufficient implementations + // explicitly. Each gets its own case. + const variants: readonly { readonly name: string; readonly mutate: (env: Record) => void; readonly detail: string }[] = [ + { + name: 'an unrecognized schemaVersion', + mutate: (env) => { + env['schemaVersion'] = '2'; + }, + detail: 'schemaVersion is "2"', + }, + { + name: 'a globDialect.version other than 4.0.5', + mutate: (env) => { + (env['globDialect'] as Record)['version'] = '4.0.4'; + }, + detail: 'globDialect.version is "4.0.4"', + }, + { + name: 'globDialect.options.dot true', + mutate: (env) => { + ((env['globDialect'] as Record)['options'] as Record)['dot'] = true; + }, + detail: 'globDialect.options.dot is true', + }, + { + name: 'globDialect.options.nocase true', + mutate: (env) => { + ((env['globDialect'] as Record)['options'] as Record)['nocase'] = true; + }, + detail: 'globDialect.options.nocase is true', + }, + { + name: 'globDialect.options.nonegate false', + mutate: (env) => { + ((env['globDialect'] as Record)['options'] as Record)['nonegate'] = false; + }, + detail: 'globDialect.options.nonegate is false', + }, + { + name: 'an empty capabilities array', + mutate: (env) => { + env['capabilities'] = []; + }, + detail: 'capabilities is []', + }, + { + name: 'an extra capability', + mutate: (env) => { + env['capabilities'] = ['pathOwnership', 'entityOwnership']; + }, + detail: 'capabilities is ["pathOwnership","entityOwnership"]', + }, + { + name: 'a different single capability', + mutate: (env) => { + env['capabilities'] = ['entityOwnership']; + }, + detail: 'capabilities is ["entityOwnership"]', + }, + ]; + + for (const variant of variants) { + test(`rejects ${variant.name} at step 3`, () => { + const envelope = JSON.parse(fixtureText('valid.json')) as Record; + variant.mutate(envelope); + const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); + + expect(result.outcome).toBe('rejected'); + if (result.outcome !== 'rejected') return; + expect(result.failedStep).toBe(3); + expect(result.reason).toBe('unrecognized-schema-or-dialect-or-capability'); + expect(result.detail).toContain(variant.detail); + }); + } +}); + +describe('step 4 rejects a mismatching digest as well as a missing one', () => { + test('a source digest that does not match the actual bytes is rejected at step 4', () => { + const envelope = JSON.parse(fixtureText('valid.json')) as Record; + (envelope['sources'] as Record[])[0]!['digest'] = 'f'.repeat(64); + const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); + + expect(result.outcome).toBe('rejected'); + if (result.outcome !== 'rejected') return; + expect(result.failedStep).toBe(4); + expect(result.reason).toBe('missing-source-digest'); + expect(result.detail).toContain('but its bytes hash to'); + // The source was actually opened and hashed — not skipped. + expect(result.examined.sourcesVerified).toEqual(['catalog-info.yaml']); + }); + + test('a source whose file cannot be read is rejected at step 4, naming the path', () => { + const envelope = JSON.parse(fixtureText('valid.json')) as Record; + (envelope['sources'] as Record[])[0]!['path'] = 'no-such-descriptor.yaml'; + const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); + + expect(result.outcome).toBe('rejected'); + if (result.outcome !== 'rejected') return; + expect(result.failedStep).toBe(4); + expect(result.detail).toContain('no-such-descriptor.yaml'); + expect(result.detail).toContain('could not be read'); + }); +}); + +describe('step 5 reads the boolean and nothing else', () => { + test('an envelope whose entities are all annotation-absent is accepted', () => { + // `snapshot-envelope.md` §7 row 1b. Rejecting on the ownership-state + // distribution is the specific bug step 5's wording exists to prevent, and + // this is the contrast case that catches it. + const result = validateEnvelope(fixtureText('all-annotation-absent.json'), VALIDATE_OPTIONS); + + expect(result.outcome).toBe('valid'); + if (result.outcome !== 'valid') return; + expect(result.examined.stepsReached).toBe(5); + expect(result.examined.entityRecordsInspected).toBe(3); + }); + + test('the valid fixture passes all five steps', () => { + const result = validateEnvelope(fixtureText('valid.json'), VALIDATE_OPTIONS); + + expect(result.outcome).toBe('valid'); + if (result.outcome !== 'valid') return; + expect(result.reason).toBeUndefined(); + expect(result.failedStep).toBeUndefined(); + expect(result.examined).toEqual({ + entityRecordsInspected: 3, + sourcesVerified: ['catalog-info.yaml'], + stepsReached: 5, + }); + }); +}); + +describe('step 2 checks every nesting level', () => { + const nested: readonly { readonly name: string; readonly mutate: (env: Record) => void }[] = [ + { name: 'repository.revision missing', mutate: (env) => { delete (env['repository'] as Record)['revision']; } }, + { name: 'globDialect.options.nocase not a boolean', mutate: (env) => { ((env['globDialect'] as Record)['options'] as Record)['nocase'] = 'false'; } }, + { name: 'completeness.wholeCatalog not a boolean', mutate: (env) => { (env['completeness'] as Record)['wholeCatalog'] = 0; } }, + { name: 'entities[0].derivedPaths not a string array', mutate: (env) => { (env['entities'] as Record[])[0]!['derivedPaths'] = ['ok', 7]; } }, + { name: 'entities[0].sourceDocument.documentIndexInFile not an integer', mutate: (env) => { ((env['entities'] as Record[])[0]!['sourceDocument'] as Record)['documentIndexInFile'] = 1.5; } }, + { name: 'entities[2].ownershipState not recognized', mutate: (env) => { (env['entities'] as Record[])[2]!['ownershipState'] = 'inferred'; } }, + { name: 'entities[0].provenance empty', mutate: (env) => { (env['entities'] as Record[])[0]!['provenance'] = ''; } }, + { name: 'entities[0].identity.allRefs empty', mutate: (env) => { ((env['entities'] as Record[])[0]!['identity'] as Record)['allRefs'] = []; } }, + { name: 'an entity record carrying a sixth field', mutate: (env) => { (env['entities'] as Record[])[0]!['rawKind'] = 'Component'; } }, + { name: 'a flatter canonicalId/refs/paths triple', mutate: (env) => { env['entities'] = [{ canonicalId: 'component:default/payments', refs: [], paths: [] }]; } }, + { name: 'an unrecognized top-level field', mutate: (env) => { env['generatedAt'] = '2026-08-05T00:00:00Z'; } }, + { name: 'sources not an array', mutate: (env) => { env['sources'] = {}; } }, + ]; + + for (const variant of nested) { + test(`rejects ${variant.name} at step 2`, () => { + const envelope = JSON.parse(fixtureText('valid.json')) as Record; + variant.mutate(envelope); + const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); + + expect(result.outcome).toBe('rejected'); + if (result.outcome !== 'rejected') return; + expect(result.failedStep).toBe(2); + expect(result.reason).toBe('missing-or-wrong-required-field'); + expect(result.detail.length).toBeGreaterThan(0); + }); + } + + for (const field of ['schemaVersion', 'repository', 'generatorVersion', 'globDialect', 'capabilities', 'completeness', 'sources', 'entities', 'digest']) { + test(`rejects an envelope missing ${field} at step 2`, () => { + const envelope = JSON.parse(fixtureText('valid.json')) as Record; + delete envelope[field]; + const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); + + expect(result.outcome).toBe('rejected'); + if (result.outcome !== 'rejected') return; + expect(result.failedStep).toBe(2); + expect(result.detail).toContain(field); + }); + } +}); diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/README.md b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/README.md new file mode 100644 index 00000000..f9cfb6ee --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/README.md @@ -0,0 +1,139 @@ +# Negative case: the consumer importing the adapter + +**Task**: T037 · **Discharges**: FR-044 (behavioural half) +**Contract**: [`package-boundary.md`](../../../contracts/package-boundary.md) §3 +**Observed against**: the Phase C working tree, `packages/catalog-envelope/src/index.ts` otherwise unmodified +**Tools**: Bun 1.3.14 +**Permanent automated case**: `packages/catalog-envelope/test/no-adapter-import.test.ts` + +Nothing under `packages/adapters/` was modified to construct either case. The +edge is introduced in the **consumer's own** source, which is the direction +FR-044 names first and the one that would make this package's validation a +tautology if it were ever real. + +## What this adds over Phase A's dependency-check evidence + +Phase A already observed both manifest-level guards firing +([`dep-consumer-to-adapter/`](../dep-consumer-to-adapter/), +[`dep-adapter-to-consumer/`](../dep-adapter-to-consumer/)). Those cover the +*declaration*. T037 asks for a **build-graph assertion, not only a `package.json` +inspection**, and case B below is the reason that distinction was written into +the task. + +## Case B — the relative-path import, and the gap it exposes + +[`case-b-relative-path-import.patch`](./case-b-relative-path-import.patch) adds +to `packages/catalog-envelope/src/index.ts`: + +```ts +import { PACKAGE_NAME as ADAPTER_NAME } from '../../adapters/catalog-backstage/src/index.ts'; +``` + +This declares nothing. It compiles, it resolves, it builds, and the module is +genuinely in the consumer's build graph. + +| Check | Command | Result | +|---|---|---| +| Manifest dependency check | `bun run check:deps` | **exit 0**, prints `core-has-no-adapter-deps: ok` — [`case-b-relative-path-import.check-deps.observed.txt`](./case-b-relative-path-import.check-deps.observed.txt) | +| This test | `bun test packages/catalog-envelope/test/no-adapter-import.test.ts` | **exit 1**, 2 pass / 2 fail — [`case-b-relative-path-import.observed.txt`](./case-b-relative-path-import.observed.txt) | + +The green `check:deps` is the point. It is not wrong — the manifest genuinely +declares no adapter dependency — but it is green about a question that is no +longer the one being asked. Recording the two side by side is the evidence that +the build-graph assertion covers something the manifest check structurally +cannot. + +The failing assertion names the module it found: + +```text +const offending = modules.filter((path) => path.includes(FORBIDDEN_PATH_SEGMENT)); +expect(offending).toEqual([]) + +- [] ++ [ "packages/adapters/catalog-backstage/src/index.ts" ] +``` + +### A defect in this test, found by this observation and fixed + +On the first run of case B, the build-graph assertion failed as expected but the +companion source-scan assertion **passed**. The scan tested whether an import +specifier contained the literal string `packages/adapters/`, and +`../../adapters/catalog-backstage/src/index.ts` does not contain it. The scan was +blind to exactly the specifier form that also evades `check:deps`. + +The scan now resolves relative specifiers against the importing file's directory +before testing them, and the captured output above is from the re-run, in which +both assertions fail: + +```text +(fail) the consumer imports nothing from an adapter > + the build graph contains no module under packages/adapters/ +(fail) the consumer imports nothing from an adapter > + no source file names any adapter package or its path +``` + +This is what ADR-0016 is for: the check was written, looked correct, passed, and +did not work. Only constructing the violation said so. + +### Why the test derives adapter names instead of naming one + +The forbidden package name is read from `packages/adapters/*/package.json` at run +time rather than written as a literal. That is the stronger rule — FR-003 and +FR-044 forbid reaching **any** adapter, not one named adapter — and it is also +forced: Phase A's locality guard at +`packages/adapters/catalog-backstage/test/envelope-shape-locality.test.ts` +forbids any `.ts` file under the consumer from naming the adapter package, which +a self-referential guard would otherwise have to do. Phase A resolves the same +problem for its own guards with an `EXCLUDED_FROM_SCAN` list, but that list lives +in the adapter's tree, which Phase C does not own. This is reported for central +reconciliation, not treated as settled. + +## Case A — the package-name import, reported precisely + +[`case-a-package-name-import.patch`](./case-a-package-name-import.patch) adds: + +```ts +import { PACKAGE_NAME as ADAPTER_NAME } from '@adrkit/catalog-backstage'; +``` + +[`case-a-package-name-import.observed.txt`](./case-a-package-name-import.observed.txt) — +**exit 1**, 0 pass / 1 fail / 1 error: + +```text +error: Cannot find module '@adrkit/catalog-backstage' from + '…/packages/catalog-envelope/src/index.ts' +``` + +**Stated plainly rather than counted as a detection**: this case goes red, but at +*module resolution*, not because the graph assertion saw anything. The consumer +declares no dependency on the adapter, so Bun's isolated linker never links it, +and the import cannot resolve at all. Reaching the adapter this way would require +also amending `packages/catalog-envelope/package.json` — which is the edge +Phase A already observed `check:deps` rejecting with +`non-adapter workspace depends on an adapter package`. + +So case A demonstrates that the package-name route is closed by a **different** +mechanism than the one this test provides, and it is recorded that way. Claiming +it as a build-graph detection would overstate what the output shows. `check:deps` +also exits 0 on case A, for the same manifest-level reason as case B. + +## Restored + +- [`restored.observed.txt`](./restored.observed.txt) — **4 pass, 0 fail**, exit 0 +- [`restored.check-deps.observed.txt`](./restored.check-deps.observed.txt) — exit 0 + +## Reproducing + +```bash +git apply specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-b-relative-path-import.patch +bun run check:deps # exit 0 — the gap +bun test packages/catalog-envelope/test/no-adapter-import.test.ts # exit 1 +git checkout -- packages/catalog-envelope/src/index.ts +bun test packages/catalog-envelope/test/no-adapter-import.test.ts # exit 0 +``` + +## Standing constraints + +ADR-0014 **rung 1 only** — not reference-verified (rung 2), not externally +validated (rung 3). Maintainer-owned observation, which is not external, +third-party, or community validation. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-a-package-name-import.check-deps.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-a-package-name-import.check-deps.observed.txt new file mode 100644 index 00000000..2a37a18b --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-a-package-name-import.check-deps.observed.txt @@ -0,0 +1,9 @@ +# Observed: the consumer imports the adapter by package name — what the manifest-level dependency check says about it + +$ bun run check:deps + +core-has-no-adapter-deps: ok + +$ bun run scripts/check-deps.ts + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-a-package-name-import.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-a-package-name-import.observed.txt new file mode 100644 index 00000000..4a4b4b88 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-a-package-name-import.observed.txt @@ -0,0 +1,21 @@ +# Observed: the consumer imports the adapter by package name + +$ bun test packages/catalog-envelope/test/no-adapter-import.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/no-adapter-import.test.ts: + +# Unhandled error between tests +------------------------------- +error: Cannot find module '@adrkit/catalog-backstage' from '/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/src/index.ts' +------------------------------- + + + 0 pass + 1 fail + 1 error +Ran 1 test across 1 file. [12.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-a-package-name-import.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-a-package-name-import.patch new file mode 100644 index 00000000..0cd614e6 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-a-package-name-import.patch @@ -0,0 +1,17 @@ +diff --git a/packages/catalog-envelope/src/index.ts b/packages/catalog-envelope/src/index.ts +index c16dc0e..67c0dde 100644 +--- a/packages/catalog-envelope/src/index.ts ++++ b/packages/catalog-envelope/src/index.ts +@@ -39,8 +39,12 @@ + * statically importing this module, rather than asserting an absence and calling + * that coverage (ADR-0016 clause 3). + */ ++import { PACKAGE_NAME as ADAPTER_NAME } from '@adrkit/catalog-backstage'; ++ + export const PACKAGE_NAME = '@adrkit/catalog-envelope'; + ++export const OBSERVED_ADAPTER_NAME = ADAPTER_NAME; ++ + export { + ENTITY_RECORD_FIELDS, + ENVELOPE_SCHEMA_VERSION, diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-b-relative-path-import.check-deps.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-b-relative-path-import.check-deps.observed.txt new file mode 100644 index 00000000..111c3d4d --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-b-relative-path-import.check-deps.observed.txt @@ -0,0 +1,9 @@ +# Observed: the consumer reaches across the workspace by relative path — what the manifest-level dependency check says about it + +$ bun run check:deps + +core-has-no-adapter-deps: ok + +$ bun run scripts/check-deps.ts + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-b-relative-path-import.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-b-relative-path-import.observed.txt new file mode 100644 index 00000000..ef6d39c7 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-b-relative-path-import.observed.txt @@ -0,0 +1,55 @@ +# Observed: the consumer reaches across the workspace by relative path + +$ bun test packages/catalog-envelope/test/no-adapter-import.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/no-adapter-import.test.ts: +115 | expect(modules.length).toBeGreaterThan(20); +116 | expect(modules.some((path) => path.includes('packages/catalog-envelope/src/'))).toBe(true); +117 | expect(modules.some((path) => path.includes('packages/core/src/'))).toBe(true); +118 | +119 | const offending = modules.filter((path) => path.includes(FORBIDDEN_PATH_SEGMENT)); +120 | expect(offending).toEqual([]); + ^ +error: expect(received).toEqual(expected) + +- [] ++ [ ++ "packages/adapters/catalog-backstage/src/index.ts", ++ ] + +- Expected - 1 ++ Received + 3 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/no-adapter-import.test.ts:120:23) +(fail) the consumer imports nothing from an adapter > the build graph contains no module under packages/adapters/ [10.05ms] +151 | offending.push(`${file.slice(PACKAGE_ROOT.length + 1)}: ${specifier}`); +152 | } +153 | } +154 | } +155 | +156 | expect(offending).toEqual([]); + ^ +error: expect(received).toEqual(expected) + +- [] ++ [ ++ "src/index.ts: ../../adapters/catalog-backstage/src/index.ts", ++ ] + +- Expected - 1 ++ Received + 3 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/no-adapter-import.test.ts:156:23) +(fail) the consumer imports nothing from an adapter > no source file names any adapter package or its path [0.74ms] +(pass) the consumer imports nothing from an adapter > the consumer declares no dependency on any adapter [0.13ms] +(pass) the consumer imports nothing from an adapter > the package under test is the consumer, statically imported [0.08ms] + + 2 pass + 2 fail + 14 expect() calls +Ran 4 tests across 1 file. [56.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-b-relative-path-import.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-b-relative-path-import.patch new file mode 100644 index 00000000..97f06416 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/case-b-relative-path-import.patch @@ -0,0 +1,17 @@ +diff --git a/packages/catalog-envelope/src/index.ts b/packages/catalog-envelope/src/index.ts +index c16dc0e..23d680d 100644 +--- a/packages/catalog-envelope/src/index.ts ++++ b/packages/catalog-envelope/src/index.ts +@@ -39,8 +39,12 @@ + * statically importing this module, rather than asserting an absence and calling + * that coverage (ADR-0016 clause 3). + */ ++import { PACKAGE_NAME as ADAPTER_NAME } from '../../adapters/catalog-backstage/src/index.ts'; ++ + export const PACKAGE_NAME = '@adrkit/catalog-envelope'; + ++export const OBSERVED_ADAPTER_NAME = ADAPTER_NAME; ++ + export { + ENTITY_RECORD_FIELDS, + ENVELOPE_SCHEMA_VERSION, diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/restored.check-deps.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/restored.check-deps.observed.txt new file mode 100644 index 00000000..8bb724f5 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/restored.check-deps.observed.txt @@ -0,0 +1,9 @@ +# Restored: no adapter edge + +$ bun run check:deps + +core-has-no-adapter-deps: ok + +$ bun run scripts/check-deps.ts + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/restored.observed.txt new file mode 100644 index 00000000..4712ac52 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-adapter-import/restored.observed.txt @@ -0,0 +1,19 @@ +# Restored: no adapter edge + +$ bun test packages/catalog-envelope/test/no-adapter-import.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/no-adapter-import.test.ts: +(pass) the consumer imports nothing from an adapter > the build graph contains no module under packages/adapters/ [11.86ms] +(pass) the consumer imports nothing from an adapter > no source file names any adapter package or its path [0.73ms] +(pass) the consumer imports nothing from an adapter > the consumer declares no dependency on any adapter [0.12ms] +(pass) the consumer imports nothing from an adapter > the package under test is the consumer, statically imported [0.05ms] + + 4 pass + 0 fail + 14 expect() calls +Ran 4 tests across 1 file. [58.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/README.md b/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/README.md new file mode 100644 index 00000000..a8cd85dc --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/README.md @@ -0,0 +1,85 @@ +# Negative case: the pinned core and published-schema surfaces + +**Task**: T026 · **Discharges**: FR-004 (and FR-005's "never part of any published schema" half) +**Observed against**: the Phase C working tree; the two mutated files reverted after each run +**Tools**: Bun 1.3.14 +**Permanent automated case**: `packages/catalog-envelope/test/no-core-schema-change.test.ts` + +## Why the guard is a digest pin + +The obvious version of this check — "assert `SnapshotEnvelope` does not appear in +`packages/core`" — asserts an **absence**, and ADR-0016 clause 3 is explicit that +an absence and a blind check render identically. It is also the wrong shape: it +would pass while somebody widened `CatalogSnapshotEntity` with a `derivedPaths` +or `ownershipState` field, which is a different route to the same forbidden +outcome. + +So the guard pins a **specific observed value** per file — the SHA-256 of its +bytes at `99ba8d2500eaf37625ea164f66b4a17870e40dad` — across all five files under +`packages/core/src/affects/`, plus `packages/core/src/schema/adr.schema.ts` and +`schema/adr.schema.json`, and additionally pins the file list under `affects/` so +that adding or removing a file is caught too. + +## Case A — a core type widened + +[`case-a-catalog-type-widened.patch`](./case-a-catalog-type-widened.patch) adds +an optional `ownershipState?: string` to `CatalogSnapshotEntity` — the most +plausible way this rule gets broken in practice, since the envelope preserves a +distinction the core type cannot express and adding the field "just to keep it" +looks harmless. + +[`case-a-catalog-type-widened.observed.txt`](./case-a-catalog-type-widened.observed.txt) — +exit **1**, **12 pass, 1 fail**: + +```text +(fail) the protected core and schema surfaces are unchanged > + packages/core/src/affects/catalog.ts is byte-identical to its pin +``` + +## Case B — the published schema touched + +[`case-b-published-schema-touched.patch`](./case-b-published-schema-touched.patch) +inserts an `x-derivedPaths` key into `schema/adr.schema.json`. + +[`case-b-published-schema-touched.observed.txt`](./case-b-published-schema-touched.observed.txt) — +exit **1**, **11 pass, 2 fail**: + +```text +(fail) the protected core and schema surfaces are unchanged > + schema/adr.schema.json is byte-identical to its pin +(fail) the envelope is not smuggled into the core types > + the published schema mentions no envelope vocabulary +``` + +Two guards fire, which is the intended overlap: the digest pin catches *any* +change, and the vocabulary assertion names *which* forbidden term appeared. The +vocabulary assertion alone would be an absence check; it is kept because it says +something specific, and it is meaningful only because the digest pin sits behind +it. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — **13 pass, 0 fail**, exit 0. + +## If one of these pins fails in future + +A failure means a protected surface changed. Under this feature that is a +**violation**, not a stale pin — FR-004 is unconditional, and updating the pin to +match would be amending the expectation to fit the output. A legitimate change to +these files belongs to separately-authorized later work, and whoever makes it +takes on re-justifying this guard against whatever governs then. + +## Reproducing + +```bash +git apply specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/case-a-catalog-type-widened.patch +bun test packages/catalog-envelope/test/no-core-schema-change.test.ts # expect exit 1 +git checkout -- packages/core/src/affects/catalog.ts +bun test packages/catalog-envelope/test/no-core-schema-change.test.ts # expect exit 0 +``` + +## Standing constraints + +ADR-0014 **rung 1 only** — not reference-verified (rung 2), not externally +validated (rung 3). Maintainer-owned observation, which is not external, +third-party, or community validation. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/case-a-catalog-type-widened.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/case-a-catalog-type-widened.observed.txt new file mode 100644 index 00000000..af487b1e --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/case-a-catalog-type-widened.observed.txt @@ -0,0 +1,41 @@ +# Observed: CatalogSnapshotEntity widened with an envelope field + +$ bun test packages/catalog-envelope/test/no-core-schema-change.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/no-core-schema-change.test.ts: +(pass) the protected core and schema surfaces are unchanged > the pin table covers seven files, each with a digest and a stated reason [0.08ms] +102 | } +103 | }); +104 | +105 | for (const pin of PINS.protectedFiles) { +106 | test(`${pin.path} is byte-identical to its pin`, () => { +107 | expect(digestOf(pin.path)).toBe(pin.sha256); + ^ +error: expect(received).toBe(expected) + +Expected: "7d0f3f30e5ff7e98a4ba931df6c242118a7a3dd9a6c726d7cfdf6ca69d52122e" +Received: "6e217605e31cabd65c0e04033232fbca43ab74bb4057b6aa18884b1e1ea1ed0b" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/no-core-schema-change.test.ts:107:34) +(fail) the protected core and schema surfaces are unchanged > packages/core/src/affects/catalog.ts is byte-identical to its pin [0.32ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/index.ts is byte-identical to its pin [0.04ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/inert.ts is byte-identical to its pin [0.02ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/matchers/package.ts is byte-identical to its pin [0.03ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/matchers/path.ts is byte-identical to its pin [0.04ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/schema/adr.schema.ts is byte-identical to its pin [0.02ms] +(pass) the protected core and schema surfaces are unchanged > schema/adr.schema.json is byte-identical to its pin [0.26ms] +(pass) the protected core and schema surfaces are unchanged > no file has been added to or removed from the affects tree [0.17ms] +(pass) the envelope is not smuggled into the core types > CatalogSnapshotEntity still has exactly id, refs and paths [0.05ms] +(pass) the envelope is not smuggled into the core types > CatalogSnapshot still has exactly entities [0.01ms] +(pass) the envelope is not smuggled into the core types > CatalogPort still has exactly its three methods [0.02ms] +(pass) the envelope is not smuggled into the core types > the published schema mentions no envelope vocabulary [0.11ms] + + 12 pass + 1 fail + 29 expect() calls +Ran 13 tests across 1 file. [15.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/case-a-catalog-type-widened.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/case-a-catalog-type-widened.patch new file mode 100644 index 00000000..48935ef2 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/case-a-catalog-type-widened.patch @@ -0,0 +1,12 @@ +diff --git a/packages/core/src/affects/catalog.ts b/packages/core/src/affects/catalog.ts +index 4a6c7b9..4d8f12c 100644 +--- a/packages/core/src/affects/catalog.ts ++++ b/packages/core/src/affects/catalog.ts +@@ -2,6 +2,7 @@ export type EntityId = string; + + export interface CatalogSnapshotEntity { + id: EntityId; ++ ownershipState?: string; + refs?: readonly string[]; + paths?: readonly string[]; + } diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/case-b-published-schema-touched.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/case-b-published-schema-touched.observed.txt new file mode 100644 index 00000000..de2e295a --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/case-b-published-schema-touched.observed.txt @@ -0,0 +1,59 @@ +# Observed: the published ADR schema gains envelope vocabulary + +$ bun test packages/catalog-envelope/test/no-core-schema-change.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/no-core-schema-change.test.ts: +(pass) the protected core and schema surfaces are unchanged > the pin table covers seven files, each with a digest and a stated reason [0.07ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/catalog.ts is byte-identical to its pin [0.12ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/index.ts is byte-identical to its pin [0.03ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/inert.ts is byte-identical to its pin [0.02ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/matchers/package.ts is byte-identical to its pin [0.02ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/matchers/path.ts is byte-identical to its pin [0.03ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/schema/adr.schema.ts is byte-identical to its pin [0.03ms] +102 | } +103 | }); +104 | +105 | for (const pin of PINS.protectedFiles) { +106 | test(`${pin.path} is byte-identical to its pin`, () => { +107 | expect(digestOf(pin.path)).toBe(pin.sha256); + ^ +error: expect(received).toBe(expected) + +Expected: "1e1841151174cc5a8ed22dadae070f087477e5068bd928d5292c3acd2e2681cc" +Received: "29c7ecb183d56afbb8e38e70bf5f23539ccc077e8bdb6b8524a904f15c485068" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/no-core-schema-change.test.ts:107:34) +(fail) the protected core and schema surfaces are unchanged > schema/adr.schema.json is byte-identical to its pin [0.14ms] +(pass) the protected core and schema surfaces are unchanged > no file has been added to or removed from the affects tree [0.12ms] +(pass) the envelope is not smuggled into the core types > CatalogSnapshotEntity still has exactly id, refs and paths [0.01ms] +(pass) the envelope is not smuggled into the core types > CatalogSnapshot still has exactly entities +(pass) the envelope is not smuggled into the core types > CatalogPort still has exactly its three methods [0.01ms] +144 | // meaningful; this states the specific thing FR-005 forbids. +145 | expect(published.length).toBeGreaterThan(1000); +146 | expect(PINS.forbiddenSchemaVocabulary.length).toBeGreaterThanOrEqual(5); +147 | +148 | const found = PINS.forbiddenSchemaVocabulary.filter((term) => published.includes(term)); +149 | expect(found).toEqual([]); + ^ +error: expect(received).toEqual(expected) + +- [] ++ [ ++ "derivedPaths", ++ ] + +- Expected - 1 ++ Received + 3 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/no-core-schema-change.test.ts:149:19) +(fail) the envelope is not smuggled into the core types > the published schema mentions no envelope vocabulary [0.11ms] + + 11 pass + 2 fail + 29 expect() calls +Ran 13 tests across 1 file. [15.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/case-b-published-schema-touched.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/case-b-published-schema-touched.patch new file mode 100644 index 00000000..4407e4da --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/case-b-published-schema-touched.patch @@ -0,0 +1,12 @@ +diff --git a/schema/adr.schema.json b/schema/adr.schema.json +index 87c4c19..594469c 100644 +--- a/schema/adr.schema.json ++++ b/schema/adr.schema.json +@@ -1,6 +1,6 @@ + { + "$id": "https://adrkit.dev/schema/adr/v0.1.0/adr.schema.json", +- "$schema": "https://json-schema.org/draft/2020-12/schema", ++ "x-derivedPaths": [], "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Typed frontmatter for a decision record. A superset of MADR: every MADR field is present or derivable, plus governance, routing, provenance, and enforcement metadata. The markdown body below the frontmatter carries the prose.", + "properties": { diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/restored.observed.txt new file mode 100644 index 00000000..3889eec1 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-core-surface-pin/restored.observed.txt @@ -0,0 +1,28 @@ +# Restored + +$ bun test packages/catalog-envelope/test/no-core-schema-change.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/no-core-schema-change.test.ts: +(pass) the protected core and schema surfaces are unchanged > the pin table covers seven files, each with a digest and a stated reason [0.08ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/catalog.ts is byte-identical to its pin [0.16ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/index.ts is byte-identical to its pin [0.03ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/inert.ts is byte-identical to its pin [0.02ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/matchers/package.ts is byte-identical to its pin [0.02ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/affects/matchers/path.ts is byte-identical to its pin [0.03ms] +(pass) the protected core and schema surfaces are unchanged > packages/core/src/schema/adr.schema.ts is byte-identical to its pin [0.02ms] +(pass) the protected core and schema surfaces are unchanged > schema/adr.schema.json is byte-identical to its pin [0.02ms] +(pass) the protected core and schema surfaces are unchanged > no file has been added to or removed from the affects tree [0.13ms] +(pass) the envelope is not smuggled into the core types > CatalogSnapshotEntity still has exactly id, refs and paths [0.02ms] +(pass) the envelope is not smuggled into the core types > CatalogSnapshot still has exactly entities +(pass) the envelope is not smuggled into the core types > CatalogPort still has exactly its three methods [0.02ms] +(pass) the envelope is not smuggled into the core types > the published schema mentions no envelope vocabulary [0.07ms] + + 13 pass + 0 fail + 29 expect() calls +Ran 13 tests across 1 file. [13.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/README.md b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/README.md new file mode 100644 index 00000000..6093a8da --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/README.md @@ -0,0 +1,107 @@ +# Negative case: a correctness claim, and a rung claim + +**Task**: T035 · **Discharges**: FR-058 (consumer framing half), SC-012 (framing half) +**Observed against**: the Phase C working tree; each mutated file reverted after its run +**Tools**: Bun 1.3.14 +**Permanent automated case**: `packages/catalog-envelope/test/no-correctness-claim.test.ts` + +## What is being defended + +ADR-0020 clause 5: a populated, digest-verified envelope proves **integrity, not +correctness** — a semantically wrong envelope can carry a perfectly valid +self-digest. SC-012 requires that no artifact, report, or document produced under +this feature present such an envelope as evidence of semantic correctness. + +Prose alone does not hold this. The framing is easy to state once in a README and +then quietly undo in an identifier, an error string, or a sentence written six +months later by someone summarising the package. So the guard scans three +surfaces: the exported names, the strings the package actually emits across the +whole fixture corpus, and its documentation including every source file's own +doc comments. + +The forbidden list is deliberately narrow. It targets phrases asserting semantic +rightness about ownership or the catalog — not the word "valid", which is the +contract's own vocabulary (`snapshot-envelope.md` §2's "valid JSON", §6's +"individually valid"). A check banning "valid" outright would be unsatisfiable +against the contract it enforces. + +## Case A — the README presents a digest match as correctness + +[`case-a-readme-claims-correctness.patch`](./case-a-readme-claims-correctness.patch) +adds to the README: + +> A digest match means the recorded ownership is correct ownership, and the check +> is tamper-proof. + +Two forbidden claims in one sentence — `correct ownership` and `tamper-proof`. +The second is the one FR-041 names specifically: the digest does not resist an +adversary who mutates content and recomputes it. + +[`case-a-readme-claims-correctness.observed.txt`](./case-a-readme-claims-correctness.observed.txt) — +exit **1**, **6 pass, 1 fail**: + +```text +(fail) the documentation makes no correctness claim > no document carries a forbidden claim +``` + +## Case B — a rung claim + +[`case-b-rung-claim.patch`](./case-b-rung-claim.patch) adds: + +> This package is reference-verified and battle-tested. + +ADR-0014 rung 1 only, per ADR-0020's closing paragraph and `spec.md` FR-062. The +guard permits these terms **when denied** — "is **not** reference-verified" is +the required framing and appears throughout this package — and rejects them when +asserted, by inspecting the forty characters preceding each occurrence for a +negation. + +[`case-b-rung-claim.observed.txt`](./case-b-rung-claim.observed.txt) — exit **1**, +**6 pass, 1 fail**: + +```text +(fail) the documentation makes no correctness claim > no document claims rung 2 or rung 3 standing +``` + +That case A and case B fail *different* assertions is the point: the correctness +guard and the rung guard are separate rules, and a single mutation failing both +would not have shown that. + +## Case C — an emitted string overclaims + +[`case-c-emitted-string-claims-correctness.patch`](./case-c-emitted-string-claims-correctness.patch) +prefixes `DIGEST_GUARANTEE_SCOPE` — the string carried on **every** +`DigestCheckResult`, so that a caller serializing a result into evidence cannot +drop the scope — with "This check is tamper-proof." + +[`case-c-emitted-string-claims-correctness.observed.txt`](./case-c-emitted-string-claims-correctness.observed.txt) — +exit **1**, **5 pass, 2 fail**: + +```text +(fail) the strings this package emits make no correctness claim > no emitted string carries a forbidden claim +(fail) the documentation makes no correctness claim > no document carries a forbidden claim +``` + +Both fire because the constant is both an emitted string and source text. This is +the case that matters most operationally: a claim in a README misleads a reader, +but a claim in an emitted string propagates into whatever evidence bundle quotes +the result. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — **7 pass, 0 fail**, exit 0. + +## Reproducing + +```bash +git apply specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-c-emitted-string-claims-correctness.patch +bun test packages/catalog-envelope/test/no-correctness-claim.test.ts # expect exit 1 +git checkout -- packages/catalog-envelope/src/digest/index.ts +bun test packages/catalog-envelope/test/no-correctness-claim.test.ts # expect exit 0 +``` + +## Standing constraints + +ADR-0014 **rung 1 only** — not reference-verified (rung 2), not externally +validated (rung 3). Maintainer-owned observation, which is not external, +third-party, or community validation. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-a-readme-claims-correctness.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-a-readme-claims-correctness.observed.txt new file mode 100644 index 00000000..51974b58 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-a-readme-claims-correctness.observed.txt @@ -0,0 +1,42 @@ +# Observed: the README presents a digest-verified envelope as evidence of correctness + +$ bun test packages/catalog-envelope/test/no-correctness-claim.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/no-correctness-claim.test.ts: +(pass) the exported surface makes no correctness claim > no exported name asserts correctness [0.95ms] +(pass) the exported surface makes no correctness claim > the exported vocabulary is about admission and checking, not judgement [0.08ms] +(pass) the strings this package emits make no correctness claim > no emitted string carries a forbidden claim [2.33ms] +(pass) the strings this package emits make no correctness claim > the one string that mentions correctness denies it [0.02ms] +192 | const haystack = stripLegitimateCorrectnessMentions(doc.text).toLowerCase(); +193 | for (const claim of FORBIDDEN_CLAIMS) { +194 | if (haystack.includes(claim)) offending.push(`${doc.name}: ${claim}`); +195 | } +196 | } +197 | expect(offending).toEqual([]); + ^ +error: expect(received).toEqual(expected) + +- [] ++ [ ++ "README.md: ownership is correct", ++ "README.md: correct ownership", ++ "README.md: tamper-proof", ++ ] + +- Expected - 1 ++ Received + 5 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/no-correctness-claim.test.ts:197:23) +(fail) the documentation makes no correctness claim > no document carries a forbidden claim [1.31ms] +(pass) the documentation makes no correctness claim > the README states the distinction before it states anything else [0.15ms] +(pass) the documentation makes no correctness claim > no document claims rung 2 or rung 3 standing [0.66ms] + + 6 pass + 1 fail + 412 expect() calls +Ran 7 tests across 1 file. [52.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-a-readme-claims-correctness.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-a-readme-claims-correctness.patch new file mode 100644 index 00000000..9d953bc9 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-a-readme-claims-correctness.patch @@ -0,0 +1,14 @@ +diff --git a/packages/catalog-envelope/README.md b/packages/catalog-envelope/README.md +index 9a41e17..50ae925 100644 +--- a/packages/catalog-envelope/README.md ++++ b/packages/catalog-envelope/README.md +@@ -5,6 +5,9 @@ every check passes — derives a `CatalogSnapshot`-shaped artifact from it. + + This package reads envelopes. It never generates them. + ++A digest match means the recorded ownership is correct ownership, and the check ++is tamper-proof. ++ + --- + + ## An integrity validator, not a correctness oracle diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-b-rung-claim.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-b-rung-claim.observed.txt new file mode 100644 index 00000000..361222ea --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-b-rung-claim.observed.txt @@ -0,0 +1,40 @@ +# Observed: the README claims rung-2 standing + +$ bun test packages/catalog-envelope/test/no-correctness-claim.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/no-correctness-claim.test.ts: +(pass) the exported surface makes no correctness claim > no exported name asserts correctness [0.62ms] +(pass) the exported surface makes no correctness claim > the exported vocabulary is about admission and checking, not judgement [0.57ms] +(pass) the strings this package emits make no correctness claim > no emitted string carries a forbidden claim [2.10ms] +(pass) the strings this package emits make no correctness claim > the one string that mentions correctness denies it [0.01ms] +(pass) the documentation makes no correctness claim > no document carries a forbidden claim [1.11ms] +(pass) the documentation makes no correctness claim > the README states the distinction before it states anything else [0.20ms] +237 | offending.push(`${doc.name}: ${term}`); +238 | } +239 | } +240 | } +241 | } +242 | expect(offending).toEqual([]); + ^ +error: expect(received).toEqual(expected) + +- [] ++ [ ++ "README.md: battle-tested", ++ ] + +- Expected - 1 ++ Received + 3 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/no-correctness-claim.test.ts:242:23) +(fail) the documentation makes no correctness claim > no document claims rung 2 or rung 3 standing [0.91ms] + + 6 pass + 1 fail + 412 expect() calls +Ran 7 tests across 1 file. [50.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-b-rung-claim.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-b-rung-claim.patch new file mode 100644 index 00000000..ede81e63 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-b-rung-claim.patch @@ -0,0 +1,13 @@ +diff --git a/packages/catalog-envelope/README.md b/packages/catalog-envelope/README.md +index 9a41e17..3dd798b 100644 +--- a/packages/catalog-envelope/README.md ++++ b/packages/catalog-envelope/README.md +@@ -5,6 +5,8 @@ every check passes — derives a `CatalogSnapshot`-shaped artifact from it. + + This package reads envelopes. It never generates them. + ++This package is reference-verified and battle-tested. ++ + --- + + ## An integrity validator, not a correctness oracle diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-c-emitted-string-claims-correctness.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-c-emitted-string-claims-correctness.observed.txt new file mode 100644 index 00000000..6430a653 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-c-emitted-string-claims-correctness.observed.txt @@ -0,0 +1,53 @@ +# Observed: an emitted string overclaims adversarial tamper-resistance + +$ bun test packages/catalog-envelope/test/no-correctness-claim.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/no-correctness-claim.test.ts: +(pass) the exported surface makes no correctness claim > no exported name asserts correctness [0.61ms] +(pass) the exported surface makes no correctness claim > the exported vocabulary is about admission and checking, not judgement [0.06ms] +153 | expect(strings.length).toBeGreaterThan(20); +154 | +155 | for (const emitted of strings) { +156 | const haystack = stripLegitimateCorrectnessMentions(emitted).toLowerCase(); +157 | for (const claim of FORBIDDEN_CLAIMS) { +158 | expect(haystack).not.toContain(claim); + ^ +error: expect(received).not.toContain(expected) + +Expected to not contain: "tamper-proof" +Received: "this check is tamper-proof. detects accidental corruption and naive mutation only. does not resist an adversary who mutates content and recomputes the same digest. a digest match proves : a semantically wrong envelope can carry a valid self-digest." + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/no-correctness-claim.test.ts:158:30) +(fail) the strings this package emits make no correctness claim > no emitted string carries a forbidden claim [2.49ms] +(pass) the strings this package emits make no correctness claim > the one string that mentions correctness denies it [0.02ms] +192 | const haystack = stripLegitimateCorrectnessMentions(doc.text).toLowerCase(); +193 | for (const claim of FORBIDDEN_CLAIMS) { +194 | if (haystack.includes(claim)) offending.push(`${doc.name}: ${claim}`); +195 | } +196 | } +197 | expect(offending).toEqual([]); + ^ +error: expect(received).toEqual(expected) + +- [] ++ [ ++ "src/digest/index.ts: tamper-proof", ++ ] + +- Expected - 1 ++ Received + 3 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/no-correctness-claim.test.ts:197:23) +(fail) the documentation makes no correctness claim > no document carries a forbidden claim [0.83ms] +(pass) the documentation makes no correctness claim > the README states the distinction before it states anything else [0.13ms] +(pass) the documentation makes no correctness claim > no document claims rung 2 or rung 3 standing [0.57ms] + + 5 pass + 2 fail + 23 expect() calls +Ran 7 tests across 1 file. [47.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-c-emitted-string-claims-correctness.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-c-emitted-string-claims-correctness.patch new file mode 100644 index 00000000..1f901565 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/case-c-emitted-string-claims-correctness.patch @@ -0,0 +1,13 @@ +diff --git a/packages/catalog-envelope/src/digest/index.ts b/packages/catalog-envelope/src/digest/index.ts +index df9a4c2..6a68468 100644 +--- a/packages/catalog-envelope/src/digest/index.ts ++++ b/packages/catalog-envelope/src/digest/index.ts +@@ -60,7 +60,7 @@ export interface DigestCheckResult { + } + + export const DIGEST_GUARANTEE_SCOPE = +- 'Detects accidental corruption and naive mutation only. Does not resist an adversary who mutates content and recomputes the same digest. A digest match proves integrity, not correctness: a semantically wrong envelope can carry a valid self-digest.' as const; ++ 'This check is tamper-proof. Detects accidental corruption and naive mutation only. Does not resist an adversary who mutates content and recomputes the same digest. A digest match proves integrity, not correctness: a semantically wrong envelope can carry a valid self-digest.' as const; + + /** + * The canonical form the digest is taken over: every field of the envelope diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/restored.observed.txt new file mode 100644 index 00000000..8ff69f05 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-correctness-claim/restored.observed.txt @@ -0,0 +1,22 @@ +# Restored + +$ bun test packages/catalog-envelope/test/no-correctness-claim.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/no-correctness-claim.test.ts: +(pass) the exported surface makes no correctness claim > no exported name asserts correctness [0.61ms] +(pass) the exported surface makes no correctness claim > the exported vocabulary is about admission and checking, not judgement [0.54ms] +(pass) the strings this package emits make no correctness claim > no emitted string carries a forbidden claim [2.02ms] +(pass) the strings this package emits make no correctness claim > the one string that mentions correctness denies it [0.02ms] +(pass) the documentation makes no correctness claim > no document carries a forbidden claim [0.82ms] +(pass) the documentation makes no correctness claim > the README states the distinction before it states anything else [0.13ms] +(pass) the documentation makes no correctness claim > no document claims rung 2 or rung 3 standing [0.59ms] + + 7 pass + 0 fail + 412 expect() calls +Ran 7 tests across 1 file. [48.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-digest/README.md b/specs/010-catalog-backstage/evidence/negative-cases/consumer-digest/README.md new file mode 100644 index 00000000..62512124 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-digest/README.md @@ -0,0 +1,80 @@ +# Negative case: the declared digest trusted instead of recomputed + +**Task**: T031 · **Discharges**: FR-041 +**Contract**: [`snapshot-envelope.md`](../../../../009-catalog-binding-viability/contracts/snapshot-envelope.md) §3 +**Observed against**: the Phase C working tree, `packages/catalog-envelope/src/` otherwise unmodified +**Tools**: Bun 1.3.14 +**Permanent automated case**: `packages/catalog-envelope/test/digest.test.ts` + +## What this check does and does not prove + +Stated first, because FR-041 requires the scope to travel with every mention of +this check rather than trail it as a footnote: + +- **Accidental-corruption and naive-mutation detection only.** It does not + resist an adversary who mutates content and also recomputes the same digest + with the same algorithm. A cryptographically-signed tamper-evidence mechanism + is an explicitly open question this feature does not attempt. +- **Integrity, not correctness** (ADR-0020 clause 5). A semantically wrong + envelope can carry a perfectly valid self-digest. A `match` says the bytes are + the bytes that were written. It says nothing about whether the ownership those + bytes record is right. + +Nothing below may be cited as evidence of either of the things named above as +excluded. + +## Input + +[`declared-digest-trusted.patch`](./declared-digest-trusted.patch) replaces the +recomputation in `checkEnvelopeDigest` with the envelope's own declared value: + +```diff +- const recomputedDigest = recomputeEnvelopeDigest(envelope); ++ const recomputedDigest = envelope.digest; +``` + +This is the realistic wrong implementation — not an obvious deletion. It reads +correctly, always reports `match`, and comparing a value against itself is the +failure `snapshot-envelope.md` §3 names when it says a consumer must +"independently recompute" rather than trust the declared value. + +## Observed + +Command: `bun test packages/catalog-envelope/test/{digest,identity,derive}.test.ts` · +Exit **1** · **38 pass, 6 fail** · +[`declared-digest-trusted.observed.txt`](./declared-digest-trusted.observed.txt) + +Failing: + +- `digest verification > the tampered fixture is rejected, and the mismatch is named` +- `digest verification > the declared digest is never trusted unconditionally` +- `digest verification > a single flipped character anywhere in the payload is detected` +- `digest verification > admission refuses the tampered fixture at the digest stage, not earlier` +- `admission runs every check in order before permitting derivation > the refusal stage is named for every fixture that cannot be admitted` +- `admission runs every check in order before permitting derivation > admission without a configured revision or repository still runs digest` + +`tampered.json` is the fixture that carries this: it gained a third element in +`entities[0].derivedPaths` **after** the digest was computed, so it passes all +five validation steps — the payload is structurally perfect. Recomputation is +the only thing that can catch it, which is why trusting the declared value makes +it sail through and why the six failures above are the check reporting that it +can see. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — **44 pass, 0 fail**, exit 0. + +## Reproducing + +```bash +git apply specs/010-catalog-backstage/evidence/negative-cases/consumer-digest/declared-digest-trusted.patch +bun test packages/catalog-envelope/test/digest.test.ts # expect exit 1 +git checkout -- packages/catalog-envelope/src/digest/index.ts +bun test packages/catalog-envelope/test/digest.test.ts # expect exit 0 +``` + +## Standing constraints + +Synthetic fixtures only; no external adopter. ADR-0014 **rung 1 only** — not +reference-verified (rung 2), not externally validated (rung 3). Maintainer-owned +observation, which is not external, third-party, or community validation. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-digest/declared-digest-trusted.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-digest/declared-digest-trusted.observed.txt new file mode 100644 index 00000000..9e2b36fd --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-digest/declared-digest-trusted.observed.txt @@ -0,0 +1,147 @@ +# Observed: the declared digest is trusted instead of recomputed + +$ bun test packages/catalog-envelope/test/digest.test.ts packages/catalog-envelope/test/identity.test.ts packages/catalog-envelope/test/derive.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/derive.test.ts: +(pass) admission runs every check in order before permitting derivation > the valid fixture is admitted with all four verdicts recorded [1.85ms] +57 | ['wrong-repository.json', 'repository-identity', 'repository-identity-mismatch'], +58 | ]; +59 | +60 | const observed = expected.map(([fixture]) => { +61 | const result = admitEnvelope(fixtureText(fixture), ADMIT_OPTIONS_A); +62 | if (result.outcome !== 'refused') throw new Error(`${fixture} was admitted`); + ^ +error: tampered.json was admitted + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/derive.test.ts:62:82) + at map (1:11) + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/derive.test.ts:60:31) +(fail) admission runs every check in order before permitting derivation > the refusal stage is named for every fixture that cannot be admitted [0.73ms] +(pass) admission runs every check in order before permitting derivation > the all-annotation-absent contrast case is admitted, not refused [0.07ms] +71 | expect(result.outcome).toBe('admitted'); +72 | }); +73 | +74 | test('admission without a configured revision or repository still runs digest', () => { +75 | const result = admitEnvelope(fixtureText('tampered.json'), { sourceBaseDir: SOURCE_BASE_DIR }); +76 | expect(result.outcome).toBe('refused'); + ^ +error: expect(received).toBe(expected) + +Expected: "refused" +Received: "admitted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/derive.test.ts:76:28) +(fail) admission runs every check in order before permitting derivation > admission without a configured revision or repository still runs digest [0.11ms] +(pass) derivation output > every entity maps to a CatalogSnapshotEntity of id, refs and paths [0.09ms] +(pass) derivation output > the derived snapshot carries only the three core fields, never envelope fields [0.06ms] +(pass) derivation output > provenance for the derivation is recorded outside the snapshot [0.05ms] +(pass) derivation output > derivation is deterministic [0.10ms] +(pass) derivation output > the snapshot does not alias the envelope arrays [0.07ms] +(pass) the mapping is lossy by design > explicit-empty and annotation-absent both map to an empty paths array [0.07ms] +(pass) the mapping is lossy by design > an all-annotation-absent envelope derives a snapshot with no paths anywhere [0.06ms] + +packages/catalog-envelope/test/identity.test.ts: +(pass) staleness is exact inequality > the stale fixture passes the digest check cleanly first [0.35ms] +(pass) staleness is exact inequality > a different revision for the same repository is stale [0.08ms] +(pass) staleness is exact inequality > an exactly equal revision is ok [0.06ms] +(pass) staleness is exact inequality > inequality is symmetric — direction is never inferred [0.10ms] +(pass) staleness is exact inequality > a lexicographically smaller revision is just as stale as a larger one [0.13ms] +(pass) staleness is exact inequality > with no expectation configured, the outcome is not-configured rather than ok [0.04ms] +(pass) staleness is exact inequality > the comparison is declared on the result and names no ordering [0.04ms] +(pass) staleness is exact inequality > an expectation configured for another repository yields no staleness verdict [0.21ms] +(pass) staleness is exact inequality > the scoping does not weaken the check for the repository it is about [0.12ms] +(pass) staleness is exact inequality > admission refuses the stale fixture at the staleness stage, not the digest stage [0.08ms] +(pass) repository identity mismatch is a rejection > the wrong-repository fixture passes the digest check cleanly first [0.05ms] +(pass) repository identity mismatch is a rejection > a different repository id is a mismatch [0.05ms] +(pass) repository identity mismatch is a rejection > the expected repository id is ok [0.04ms] +(pass) repository identity mismatch is a rejection > with no expectation configured, the outcome is not-configured rather than ok [0.05ms] +(pass) repository identity mismatch is a rejection > admission refuses at the identity stage, after staleness has passed [0.05ms] +(pass) repository identity mismatch is a rejection > the same refusal holds with no revision expectation at all [0.05ms] +(pass) repository isolation is acceptance, not rejection > a valid envelope from a different repository is admitted on its own terms [0.06ms] +(pass) repository isolation is acceptance, not rejection > a query scoped to one repository returns only that repository's entities [0.15ms] +(pass) repository isolation is acceptance, not rejection > no entity from one repository ever leaks into the other repository's result [0.11ms] +(pass) repository isolation is acceptance, not rejection > a query scoped to a repository nobody loaded returns nothing and says so [0.07ms] +(pass) repository isolation is acceptance, not rejection > neither envelope is rejected by the query, and both remain independently valid [0.08ms] + +packages/catalog-envelope/test/digest.test.ts: +(pass) canonicalization > the digest field itself is excluded and every other field is included [0.22ms] +(pass) canonicalization > object keys are sorted at every nesting level, arrays are not [0.09ms] +(pass) canonicalization > the digest is 64 lowercase hex over the UTF-8 bytes of the canonical form [0.17ms] +(pass) canonicalization > reordering the keys of an envelope does not change its digest [0.09ms] +(pass) canonicalization > recomputation is stable across repeated runs [0.12ms] +(pass) digest verification > the valid fixture matches [0.07ms] +118 | const validation = validateEnvelope(fixtureText('tampered.json'), VALIDATE_OPTIONS); +119 | expect(validation.outcome).toBe('valid'); +120 | if (validation.outcome !== 'valid') return; +121 | +122 | const result = checkEnvelopeDigest(validation.validated); +123 | expect(result.outcome).toBe('digest-mismatch'); + ^ +error: expect(received).toBe(expected) + +Expected: "digest-mismatch" +Received: "match" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/digest.test.ts:123:28) +(fail) digest verification > the tampered fixture is rejected, and the mismatch is named [0.09ms] +127 | }); +128 | +129 | test('admission refuses the tampered fixture at the digest stage, not earlier', () => { +130 | const result = admitEnvelope(fixtureText('tampered.json'), ADMIT_OPTIONS_A); +131 | +132 | expect(result.outcome).toBe('refused'); + ^ +error: expect(received).toBe(expected) + +Expected: "refused" +Received: "admitted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/digest.test.ts:132:28) +(fail) digest verification > admission refuses the tampered fixture at the digest stage, not earlier [0.08ms] +145 | '1e0f3c9a8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e30'; +146 | const validation = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); +147 | expect(validation.outcome).toBe('valid'); +148 | if (validation.outcome !== 'valid') return; +149 | +150 | expect(checkEnvelopeDigest(validation.validated).outcome).toBe('digest-mismatch'); + ^ +error: expect(received).toBe(expected) + +Expected: "digest-mismatch" +Received: "match" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/digest.test.ts:150:63) +(fail) digest verification > a single flipped character anywhere in the payload is detected [0.08ms] +158 | const validation = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); +159 | expect(validation.outcome).toBe('valid'); +160 | if (validation.outcome !== 'valid') return; +161 | +162 | const result = checkEnvelopeDigest(validation.validated); +163 | expect(result.outcome).toBe('digest-mismatch'); + ^ +error: expect(received).toBe(expected) + +Expected: "digest-mismatch" +Received: "match" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/digest.test.ts:163:28) +(fail) digest verification > the declared digest is never trusted unconditionally [0.07ms] +(pass) the guarantee scope travels with the result > every digest result carries the scope statement [0.08ms] +(pass) the guarantee scope travels with the result > the scope statement names both limits and claims neither strength [0.01ms] + +6 tests failed: +(fail) admission runs every check in order before permitting derivation > the refusal stage is named for every fixture that cannot be admitted [0.73ms] +(fail) admission runs every check in order before permitting derivation > admission without a configured revision or repository still runs digest [0.11ms] +(fail) digest verification > the tampered fixture is rejected, and the mismatch is named [0.09ms] +(fail) digest verification > admission refuses the tampered fixture at the digest stage, not earlier [0.08ms] +(fail) digest verification > a single flipped character anywhere in the payload is detected [0.08ms] +(fail) digest verification > the declared digest is never trusted unconditionally [0.07ms] + + 38 pass + 6 fail + 121 expect() calls +Ran 44 tests across 3 files. [53.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-digest/declared-digest-trusted.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-digest/declared-digest-trusted.patch new file mode 100644 index 00000000..7749c7f4 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-digest/declared-digest-trusted.patch @@ -0,0 +1,13 @@ +diff --git a/packages/catalog-envelope/src/digest/index.ts b/packages/catalog-envelope/src/digest/index.ts +index df9a4c2..d0063c9 100644 +--- a/packages/catalog-envelope/src/digest/index.ts ++++ b/packages/catalog-envelope/src/digest/index.ts +@@ -92,7 +92,7 @@ export function recomputeEnvelopeDigest(envelope: SnapshotEnvelope): string { + */ + export function checkEnvelopeDigest(validated: StructurallyValidEnvelope): DigestCheckResult { + const envelope = envelopeOf(validated); +- const recomputedDigest = recomputeEnvelopeDigest(envelope); ++ const recomputedDigest = envelope.digest; + return { + declaredDigest: envelope.digest, + recomputedDigest, diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-digest/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-digest/restored.observed.txt new file mode 100644 index 00000000..49df98dc --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-digest/restored.observed.txt @@ -0,0 +1,63 @@ +# Restored: every check present + +$ bun test packages/catalog-envelope/test/digest.test.ts packages/catalog-envelope/test/identity.test.ts packages/catalog-envelope/test/derive.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/derive.test.ts: +(pass) admission runs every check in order before permitting derivation > the valid fixture is admitted with all four verdicts recorded [1.78ms] +(pass) admission runs every check in order before permitting derivation > the refusal stage is named for every fixture that cannot be admitted [0.52ms] +(pass) admission runs every check in order before permitting derivation > the all-annotation-absent contrast case is admitted, not refused [0.08ms] +(pass) admission runs every check in order before permitting derivation > admission without a configured revision or repository still runs digest [0.08ms] +(pass) derivation output > every entity maps to a CatalogSnapshotEntity of id, refs and paths [0.10ms] +(pass) derivation output > the derived snapshot carries only the three core fields, never envelope fields [0.07ms] +(pass) derivation output > provenance for the derivation is recorded outside the snapshot [0.06ms] +(pass) derivation output > derivation is deterministic [0.13ms] +(pass) derivation output > the snapshot does not alias the envelope arrays [0.07ms] +(pass) the mapping is lossy by design > explicit-empty and annotation-absent both map to an empty paths array [0.09ms] +(pass) the mapping is lossy by design > an all-annotation-absent envelope derives a snapshot with no paths anywhere [0.08ms] + +packages/catalog-envelope/test/identity.test.ts: +(pass) staleness is exact inequality > the stale fixture passes the digest check cleanly first [0.23ms] +(pass) staleness is exact inequality > a different revision for the same repository is stale [0.10ms] +(pass) staleness is exact inequality > an exactly equal revision is ok [0.05ms] +(pass) staleness is exact inequality > inequality is symmetric — direction is never inferred [0.09ms] +(pass) staleness is exact inequality > a lexicographically smaller revision is just as stale as a larger one [0.09ms] +(pass) staleness is exact inequality > with no expectation configured, the outcome is not-configured rather than ok [0.05ms] +(pass) staleness is exact inequality > the comparison is declared on the result and names no ordering [0.05ms] +(pass) staleness is exact inequality > an expectation configured for another repository yields no staleness verdict [0.05ms] +(pass) staleness is exact inequality > the scoping does not weaken the check for the repository it is about [0.04ms] +(pass) staleness is exact inequality > admission refuses the stale fixture at the staleness stage, not the digest stage [0.09ms] +(pass) repository identity mismatch is a rejection > the wrong-repository fixture passes the digest check cleanly first [0.07ms] +(pass) repository identity mismatch is a rejection > a different repository id is a mismatch [0.05ms] +(pass) repository identity mismatch is a rejection > the expected repository id is ok [0.05ms] +(pass) repository identity mismatch is a rejection > with no expectation configured, the outcome is not-configured rather than ok [0.05ms] +(pass) repository identity mismatch is a rejection > admission refuses at the identity stage, after staleness has passed [0.06ms] +(pass) repository identity mismatch is a rejection > the same refusal holds with no revision expectation at all [0.07ms] +(pass) repository isolation is acceptance, not rejection > a valid envelope from a different repository is admitted on its own terms [0.06ms] +(pass) repository isolation is acceptance, not rejection > a query scoped to one repository returns only that repository's entities [0.13ms] +(pass) repository isolation is acceptance, not rejection > no entity from one repository ever leaks into the other repository's result [0.10ms] +(pass) repository isolation is acceptance, not rejection > a query scoped to a repository nobody loaded returns nothing and says so [0.09ms] +(pass) repository isolation is acceptance, not rejection > neither envelope is rejected by the query, and both remain independently valid [0.17ms] + +packages/catalog-envelope/test/digest.test.ts: +(pass) canonicalization > the digest field itself is excluded and every other field is included [0.12ms] +(pass) canonicalization > object keys are sorted at every nesting level, arrays are not [0.08ms] +(pass) canonicalization > the digest is 64 lowercase hex over the UTF-8 bytes of the canonical form [0.10ms] +(pass) canonicalization > reordering the keys of an envelope does not change its digest [0.08ms] +(pass) canonicalization > recomputation is stable across repeated runs [0.10ms] +(pass) digest verification > the valid fixture matches [0.10ms] +(pass) digest verification > the tampered fixture is rejected, and the mismatch is named [0.07ms] +(pass) digest verification > admission refuses the tampered fixture at the digest stage, not earlier [0.08ms] +(pass) digest verification > a single flipped character anywhere in the payload is detected [0.08ms] +(pass) digest verification > the declared digest is never trusted unconditionally [0.13ms] +(pass) the guarantee scope travels with the result > every digest result carries the scope statement [0.17ms] +(pass) the guarantee scope travels with the result > the scope statement names both limits and claims neither strength [0.02ms] + + 44 pass + 0 fail + 131 expect() calls +Ran 44 tests across 3 files. [50.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/README.md b/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/README.md new file mode 100644 index 00000000..62f354eb --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/README.md @@ -0,0 +1,90 @@ +# Negative case: the two repository outcomes, conflated in each direction + +**Task**: T033 · **Discharges**: FR-048 +**Contract**: [`snapshot-envelope.md`](../../../../009-catalog-binding-viability/contracts/snapshot-envelope.md) §5, §6 +**Observed against**: the Phase C working tree, `packages/catalog-envelope/src/` otherwise unmodified +**Tools**: Bun 1.3.14 +**Permanent automated case**: `packages/catalog-envelope/test/identity.test.ts` + +## The distinction being defended + +| Situation | Correct behaviour | Contract | +|---|---|---| +| A consumer expected **exactly one** repository and is handed an envelope declaring a different one | **Reject**, naming the mismatch | §5 | +| A tool deliberately holds **several** independently-generated, individually-valid single-repository envelopes and queries them scoped to one | **Accept all of them**; the query returns only the scoped repository's entities | §6 | + +Both failure modes are quiet. Collapsing §5 into §6 turns a genuine mismatch +into a silent filter that returns an empty result and reads as "no matches". +Collapsing §6 into nothing lets one repository's entities answer another +repository's query. Neither throws, and neither produces a wrong-looking value — +which is why each needs its own constructed failure rather than one shared one. + +`wrong-repository.json` is the same file in both cases. What differs is what the +consumer was configured to expect. Its digest is recomputed over its own actual +content, so it passes the digest check cleanly and each rejection is attributable +specifically to identity (§5) — asserted by `the wrong-repository fixture passes +the digest check cleanly first`. + +## Case A — a mismatch accepted (§5 collapsed into §6) + +[`mismatch-accepted.patch`](./mismatch-accepted.patch) disables the mismatch +branch in `checkRepositoryIdentity`, so a foreign repository id reports `ok`. + +Command: `bun test packages/catalog-envelope/test/{digest,identity,derive}.test.ts` · +Exit **1** · **40 pass, 4 fail** · +[`mismatch-accepted.observed.txt`](./mismatch-accepted.observed.txt) + +Failing: + +- `repository identity mismatch is a rejection > a different repository id is a mismatch` +- `repository identity mismatch is a rejection > admission refuses at the identity stage, after staleness has passed` +- `repository identity mismatch is a rejection > the same refusal holds with no revision expectation at all` +- `admission runs every check in order before permitting derivation > the refusal stage is named for every fixture that cannot be admitted` + +Note what did **not** fail: every §6 isolation test still passes. The mutation +is localized to the rejection path, which is the evidence that the two behaviours +are implemented separately rather than as one function wearing two names. + +## Case B — isolation leaking (§6 collapsed into nothing) + +[`isolation-leaks.patch`](./isolation-leaks.patch) removes the `continue` from +`queryEntitiesForRepository`, so out-of-scope envelopes are counted as +out-of-scope and then contribute their entities anyway. + +Command: same · Exit **1** · **41 pass, 3 fail** · +[`isolation-leaks.observed.txt`](./isolation-leaks.observed.txt) + +Failing: + +- `repository isolation is acceptance, not rejection > a query scoped to one repository returns only that repository's entities` +- `repository isolation is acceptance, not rejection > no entity from one repository ever leaks into the other repository's result` +- `repository isolation is acceptance, not rejection > a query scoped to a repository nobody loaded returns nothing and says so` + +The third of those is the one that would otherwise be easy to leave out. A query +scoped to a repository that was never loaded must return nothing **and say what +it looked at** — `repositoriesConsidered` and `envelopesOutOfScope`. Without +them, an empty result is indistinguishable from a query that never ran, which is +ADR-0016's central failure shape and not something the other two tests can catch. + +Note again what did not fail: every §5 rejection test still passes under this +mutation. The two mutations fail disjoint sets of tests, and that disjointness is +the actual evidence that the contract's distinction is implemented. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — **44 pass, 0 fail**, exit 0. + +## Reproducing + +```bash +git apply specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/mismatch-accepted.patch +bun test packages/catalog-envelope/test/identity.test.ts # expect exit 1 +git checkout -- packages/catalog-envelope/src/identity/repository.ts +bun test packages/catalog-envelope/test/identity.test.ts # expect exit 0 +``` + +## Standing constraints + +Synthetic fixtures only; no external adopter. ADR-0014 **rung 1 only** — not +reference-verified (rung 2), not externally validated (rung 3). Maintainer-owned +observation, which is not external, third-party, or community validation. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/isolation-leaks.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/isolation-leaks.observed.txt new file mode 100644 index 00000000..693f4836 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/isolation-leaks.observed.txt @@ -0,0 +1,191 @@ +# Observed: the isolation query returns every entity regardless of scope (§6 collapsed into nothing) + +$ bun test packages/catalog-envelope/test/digest.test.ts packages/catalog-envelope/test/identity.test.ts packages/catalog-envelope/test/derive.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/derive.test.ts: +(pass) admission runs every check in order before permitting derivation > the valid fixture is admitted with all four verdicts recorded [2.24ms] +(pass) admission runs every check in order before permitting derivation > the refusal stage is named for every fixture that cannot be admitted [0.72ms] +(pass) admission runs every check in order before permitting derivation > the all-annotation-absent contrast case is admitted, not refused [0.13ms] +(pass) admission runs every check in order before permitting derivation > admission without a configured revision or repository still runs digest [0.10ms] +(pass) derivation output > every entity maps to a CatalogSnapshotEntity of id, refs and paths [0.13ms] +(pass) derivation output > the derived snapshot carries only the three core fields, never envelope fields [0.12ms] +(pass) derivation output > provenance for the derivation is recorded outside the snapshot [0.11ms] +(pass) derivation output > derivation is deterministic [0.18ms] +(pass) derivation output > the snapshot does not alias the envelope arrays [0.08ms] +(pass) the mapping is lossy by design > explicit-empty and annotation-absent both map to an empty paths array [0.15ms] +(pass) the mapping is lossy by design > an all-annotation-absent envelope derives a snapshot with no paths anywhere [0.11ms] + +packages/catalog-envelope/test/identity.test.ts: +(pass) staleness is exact inequality > the stale fixture passes the digest check cleanly first [0.17ms] +(pass) staleness is exact inequality > a different revision for the same repository is stale [0.09ms] +(pass) staleness is exact inequality > an exactly equal revision is ok [0.05ms] +(pass) staleness is exact inequality > inequality is symmetric — direction is never inferred [0.10ms] +(pass) staleness is exact inequality > a lexicographically smaller revision is just as stale as a larger one [0.10ms] +(pass) staleness is exact inequality > with no expectation configured, the outcome is not-configured rather than ok [0.05ms] +(pass) staleness is exact inequality > the comparison is declared on the result and names no ordering [0.05ms] +(pass) staleness is exact inequality > an expectation configured for another repository yields no staleness verdict [0.05ms] +(pass) staleness is exact inequality > the scoping does not weaken the check for the repository it is about [0.04ms] +(pass) staleness is exact inequality > admission refuses the stale fixture at the staleness stage, not the digest stage [0.09ms] +(pass) repository identity mismatch is a rejection > the wrong-repository fixture passes the digest check cleanly first [0.08ms] +(pass) repository identity mismatch is a rejection > a different repository id is a mismatch [0.06ms] +(pass) repository identity mismatch is a rejection > the expected repository id is ok [0.06ms] +(pass) repository identity mismatch is a rejection > with no expectation configured, the outcome is not-configured rather than ok [0.07ms] +(pass) repository identity mismatch is a rejection > admission refuses at the identity stage, after staleness has passed [0.08ms] +(pass) repository identity mismatch is a rejection > the same refusal holds with no revision expectation at all [0.08ms] +(pass) repository isolation is acceptance, not rejection > a valid envelope from a different repository is admitted on its own terms [0.10ms] +199 | +200 | test('a query scoped to one repository returns only that repository\'s entities', () => { +201 | const both = [validated('valid.json'), validated('wrong-repository.json')]; +202 | +203 | const scopedToA = queryEntitiesForRepository(both, REPOSITORY_A); +204 | expect(scopedToA.returnedEntities.map((entity) => entity.identity.canonicalId)).toEqual([ + ^ +error: expect(received).toEqual(expected) + + [ + "component:default/payments", + "component:default/ledger", + "component:default/gateway", ++ "component:default/billing", + ] + +- Expected - 0 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/identity.test.ts:204:85) +(fail) repository isolation is acceptance, not rejection > a query scoped to one repository returns only that repository's entities [0.27ms] +220 | test('no entity from one repository ever leaks into the other repository\'s result', () => { +221 | const both = [validated('valid.json'), validated('wrong-repository.json')]; +222 | const a = new Set(queryEntitiesForRepository(both, REPOSITORY_A).returnedEntities.map((e) => e.identity.canonicalId)); +223 | const b = new Set(queryEntitiesForRepository(both, REPOSITORY_B).returnedEntities.map((e) => e.identity.canonicalId)); +224 | +225 | expect([...a].filter((id) => b.has(id))).toEqual([]); + ^ +error: expect(received).toEqual(expected) + +- [] ++ [ ++ "component:default/payments", ++ "component:default/ledger", ++ "component:default/gateway", ++ "component:default/billing", ++ ] + +- Expected - 1 ++ Received + 6 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/identity.test.ts:225:46) +(fail) repository isolation is acceptance, not rejection > no entity from one repository ever leaks into the other repository's result [0.20ms] +229 | +230 | test('a query scoped to a repository nobody loaded returns nothing and says so', () => { +231 | const both = [validated('valid.json'), validated('wrong-repository.json')]; +232 | const result = queryEntitiesForRepository(both, 'github.com/mbeacom/not-loaded'); +233 | +234 | expect(result.returnedEntities).toEqual([]); + ^ +error: expect(received).toEqual(expected) + +- [] ++ [ ++ { ++ "derivedPaths": [ ++ "apis/payments/**", ++ "packages/payments/**", ++ ], ++ "identity": { ++ "allRefs": [ ++ "component:default/payments", ++ ], ++ "canonicalId": "component:default/payments", ++ }, ++ "ownershipState": "explicit-paths", ++ "provenance": "synthetic", ++ "sourceDocument": { ++ "documentIndexInFile": 0, ++ "sourcePath": "catalog-info.yaml", ++ }, ++ }, ++ { ++ "derivedPaths": [], ++ "identity": { ++ "allRefs": [ ++ "component:default/ledger", ++ ], ++ "canonicalId": "component:default/ledger", ++ }, ++ "ownershipState": "explicit-empty", ++ "provenance": "synthetic", ++ "sourceDocument": { ++ "documentIndexInFile": 1, ++ "sourcePath": "catalog-info.yaml", ++ }, ++ }, ++ { ++ "derivedPaths": [], ++ "identity": { ++ "allRefs": [ ++ "component:default/gateway", ++ ], ++ "canonicalId": "component:default/gateway", ++ }, ++ "ownershipState": "annotation-absent", ++ "provenance": "synthetic", ++ "sourceDocument": { ++ "documentIndexInFile": 2, ++ "sourcePath": "catalog-info.yaml", ++ }, ++ }, ++ { ++ "derivedPaths": [ ++ "services/billing/**", ++ ], ++ "identity": { ++ "allRefs": [ ++ "component:default/billing", ++ ], ++ "canonicalId": "component:default/billing", ++ }, ++ "ownershipState": "explicit-paths", ++ "provenance": "synthetic", ++ "sourceDocument": { ++ "documentIndexInFile": 0, ++ "sourcePath": "second-catalog-info.yaml", ++ }, ++ }, ++ ] + +- Expected - 1 ++ Received + 67 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/identity.test.ts:234:37) +(fail) repository isolation is acceptance, not rejection > a query scoped to a repository nobody loaded returns nothing and says so [0.14ms] +(pass) repository isolation is acceptance, not rejection > neither envelope is rejected by the query, and both remain independently valid [0.12ms] + +packages/catalog-envelope/test/digest.test.ts: +(pass) canonicalization > the digest field itself is excluded and every other field is included [0.14ms] +(pass) canonicalization > object keys are sorted at every nesting level, arrays are not [0.06ms] +(pass) canonicalization > the digest is 64 lowercase hex over the UTF-8 bytes of the canonical form [0.13ms] +(pass) canonicalization > reordering the keys of an envelope does not change its digest [0.14ms] +(pass) canonicalization > recomputation is stable across repeated runs [0.11ms] +(pass) digest verification > the valid fixture matches [0.08ms] +(pass) digest verification > the tampered fixture is rejected, and the mismatch is named [0.08ms] +(pass) digest verification > admission refuses the tampered fixture at the digest stage, not earlier [0.07ms] +(pass) digest verification > a single flipped character anywhere in the payload is detected [0.07ms] +(pass) digest verification > the declared digest is never trusted unconditionally [0.06ms] +(pass) the guarantee scope travels with the result > every digest result carries the scope statement [0.11ms] +(pass) the guarantee scope travels with the result > the scope statement names both limits and claims neither strength [0.01ms] + +3 tests failed: +(fail) repository isolation is acceptance, not rejection > a query scoped to one repository returns only that repository's entities [0.27ms] +(fail) repository isolation is acceptance, not rejection > no entity from one repository ever leaks into the other repository's result [0.20ms] +(fail) repository isolation is acceptance, not rejection > a query scoped to a repository nobody loaded returns nothing and says so [0.14ms] + + 41 pass + 3 fail + 123 expect() calls +Ran 44 tests across 3 files. [53.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/isolation-leaks.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/isolation-leaks.patch new file mode 100644 index 00000000..a08c882d --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/isolation-leaks.patch @@ -0,0 +1,12 @@ +diff --git a/packages/catalog-envelope/src/identity/repository.ts b/packages/catalog-envelope/src/identity/repository.ts +index 2a08fc6..ba8c2e8 100644 +--- a/packages/catalog-envelope/src/identity/repository.ts ++++ b/packages/catalog-envelope/src/identity/repository.ts +@@ -123,7 +123,6 @@ export function queryEntitiesForRepository( + repositoriesConsidered.push(envelope.repository.id); + if (envelope.repository.id !== scopedRepositoryId) { + envelopesOutOfScope += 1; +- continue; + } + returnedEntities.push(...envelope.entities); + } diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/mismatch-accepted.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/mismatch-accepted.observed.txt new file mode 100644 index 00000000..9132db33 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/mismatch-accepted.observed.txt @@ -0,0 +1,119 @@ +# Observed: a foreign repository id is accepted instead of rejected (§5 collapsed into §6) + +$ bun test packages/catalog-envelope/test/digest.test.ts packages/catalog-envelope/test/identity.test.ts packages/catalog-envelope/test/derive.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/derive.test.ts: +(pass) admission runs every check in order before permitting derivation > the valid fixture is admitted with all four verdicts recorded [2.20ms] +57 | ['wrong-repository.json', 'repository-identity', 'repository-identity-mismatch'], +58 | ]; +59 | +60 | const observed = expected.map(([fixture]) => { +61 | const result = admitEnvelope(fixtureText(fixture), ADMIT_OPTIONS_A); +62 | if (result.outcome !== 'refused') throw new Error(`${fixture} was admitted`); + ^ +error: wrong-repository.json was admitted + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/derive.test.ts:62:82) + at map (1:11) + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/derive.test.ts:60:31) +(fail) admission runs every check in order before permitting derivation > the refusal stage is named for every fixture that cannot be admitted [0.76ms] +(pass) admission runs every check in order before permitting derivation > the all-annotation-absent contrast case is admitted, not refused [0.09ms] +(pass) admission runs every check in order before permitting derivation > admission without a configured revision or repository still runs digest [0.10ms] +(pass) derivation output > every entity maps to a CatalogSnapshotEntity of id, refs and paths [0.12ms] +(pass) derivation output > the derived snapshot carries only the three core fields, never envelope fields [0.09ms] +(pass) derivation output > provenance for the derivation is recorded outside the snapshot [0.07ms] +(pass) derivation output > derivation is deterministic [0.13ms] +(pass) derivation output > the snapshot does not alias the envelope arrays [0.08ms] +(pass) the mapping is lossy by design > explicit-empty and annotation-absent both map to an empty paths array [0.09ms] +(pass) the mapping is lossy by design > an all-annotation-absent envelope derives a snapshot with no paths anywhere [0.09ms] + +packages/catalog-envelope/test/identity.test.ts: +(pass) staleness is exact inequality > the stale fixture passes the digest check cleanly first [0.19ms] +(pass) staleness is exact inequality > a different revision for the same repository is stale [0.15ms] +(pass) staleness is exact inequality > an exactly equal revision is ok [0.08ms] +(pass) staleness is exact inequality > inequality is symmetric — direction is never inferred [0.10ms] +(pass) staleness is exact inequality > a lexicographically smaller revision is just as stale as a larger one [0.09ms] +(pass) staleness is exact inequality > with no expectation configured, the outcome is not-configured rather than ok [0.05ms] +(pass) staleness is exact inequality > the comparison is declared on the result and names no ordering [0.04ms] +(pass) staleness is exact inequality > an expectation configured for another repository yields no staleness verdict [0.06ms] +(pass) staleness is exact inequality > the scoping does not weaken the check for the repository it is about [0.04ms] +(pass) staleness is exact inequality > admission refuses the stale fixture at the staleness stage, not the digest stage [0.08ms] +(pass) repository identity mismatch is a rejection > the wrong-repository fixture passes the digest check cleanly first [0.05ms] +133 | }); +134 | +135 | test('a different repository id is a mismatch', () => { +136 | const result = checkRepositoryIdentity(validated('wrong-repository.json'), REPOSITORY_A); +137 | +138 | expect(result.outcome).toBe('repository-identity-mismatch'); + ^ +error: expect(received).toBe(expected) + +Expected: "repository-identity-mismatch" +Received: "ok" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/identity.test.ts:138:28) +(fail) repository identity mismatch is a rejection > a different repository id is a mismatch [0.11ms] +(pass) repository identity mismatch is a rejection > the expected repository id is ok [0.06ms] +(pass) repository identity mismatch is a rejection > with no expectation configured, the outcome is not-configured rather than ok [0.13ms] +158 | // is keyed to repository A, so it produces no verdict for a repository-B +159 | // envelope and the refusal is attributable to identity — not to a revision +160 | // comparison that was never meaningful. +161 | const result = admitEnvelope(fixtureText('wrong-repository.json'), ADMIT_OPTIONS_A); +162 | +163 | expect(result.outcome).toBe('refused'); + ^ +error: expect(received).toBe(expected) + +Expected: "refused" +Received: "admitted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/identity.test.ts:163:28) +(fail) repository identity mismatch is a rejection > admission refuses at the identity stage, after staleness has passed [0.15ms] +172 | const result = admitEnvelope(fixtureText('wrong-repository.json'), { +173 | sourceBaseDir: SOURCE_BASE_DIR, +174 | expectedRepositoryId: REPOSITORY_A, +175 | }); +176 | +177 | expect(result.outcome).toBe('refused'); + ^ +error: expect(received).toBe(expected) + +Expected: "refused" +Received: "admitted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/identity.test.ts:177:28) +(fail) repository identity mismatch is a rejection > the same refusal holds with no revision expectation at all [0.17ms] +(pass) repository isolation is acceptance, not rejection > a valid envelope from a different repository is admitted on its own terms [0.08ms] +(pass) repository isolation is acceptance, not rejection > a query scoped to one repository returns only that repository's entities [0.18ms] +(pass) repository isolation is acceptance, not rejection > no entity from one repository ever leaks into the other repository's result [0.12ms] +(pass) repository isolation is acceptance, not rejection > a query scoped to a repository nobody loaded returns nothing and says so [0.13ms] +(pass) repository isolation is acceptance, not rejection > neither envelope is rejected by the query, and both remain independently valid [0.20ms] + +packages/catalog-envelope/test/digest.test.ts: +(pass) canonicalization > the digest field itself is excluded and every other field is included [0.13ms] +(pass) canonicalization > object keys are sorted at every nesting level, arrays are not [0.07ms] +(pass) canonicalization > the digest is 64 lowercase hex over the UTF-8 bytes of the canonical form [0.16ms] +(pass) canonicalization > reordering the keys of an envelope does not change its digest [0.10ms] +(pass) canonicalization > recomputation is stable across repeated runs [0.12ms] +(pass) digest verification > the valid fixture matches [0.09ms] +(pass) digest verification > the tampered fixture is rejected, and the mismatch is named [0.07ms] +(pass) digest verification > admission refuses the tampered fixture at the digest stage, not earlier [0.07ms] +(pass) digest verification > a single flipped character anywhere in the payload is detected [0.20ms] +(pass) digest verification > the declared digest is never trusted unconditionally [0.11ms] +(pass) the guarantee scope travels with the result > every digest result carries the scope statement [0.18ms] +(pass) the guarantee scope travels with the result > the scope statement names both limits and claims neither strength [0.02ms] + +4 tests failed: +(fail) admission runs every check in order before permitting derivation > the refusal stage is named for every fixture that cannot be admitted [0.76ms] +(fail) repository identity mismatch is a rejection > a different repository id is a mismatch [0.11ms] +(fail) repository identity mismatch is a rejection > admission refuses at the identity stage, after staleness has passed [0.15ms] +(fail) repository identity mismatch is a rejection > the same refusal holds with no revision expectation at all [0.17ms] + + 40 pass + 4 fail + 122 expect() calls +Ran 44 tests across 3 files. [53.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/mismatch-accepted.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/mismatch-accepted.patch new file mode 100644 index 00000000..d1643992 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/mismatch-accepted.patch @@ -0,0 +1,13 @@ +diff --git a/packages/catalog-envelope/src/identity/repository.ts b/packages/catalog-envelope/src/identity/repository.ts +index 2a08fc6..88bb703 100644 +--- a/packages/catalog-envelope/src/identity/repository.ts ++++ b/packages/catalog-envelope/src/identity/repository.ts +@@ -65,7 +65,7 @@ export function checkRepositoryIdentity( + }; + } + +- if (declaredRepositoryId !== expectedRepositoryId) { ++ if (false && declaredRepositoryId !== expectedRepositoryId) { + return { + expectedRepositoryId, + declaredRepositoryId, diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/restored.observed.txt new file mode 100644 index 00000000..4872e54c --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-repository-identity/restored.observed.txt @@ -0,0 +1,63 @@ +# Restored: every check present + +$ bun test packages/catalog-envelope/test/digest.test.ts packages/catalog-envelope/test/identity.test.ts packages/catalog-envelope/test/derive.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/derive.test.ts: +(pass) admission runs every check in order before permitting derivation > the valid fixture is admitted with all four verdicts recorded [2.02ms] +(pass) admission runs every check in order before permitting derivation > the refusal stage is named for every fixture that cannot be admitted [0.85ms] +(pass) admission runs every check in order before permitting derivation > the all-annotation-absent contrast case is admitted, not refused [0.11ms] +(pass) admission runs every check in order before permitting derivation > admission without a configured revision or repository still runs digest [0.10ms] +(pass) derivation output > every entity maps to a CatalogSnapshotEntity of id, refs and paths [0.13ms] +(pass) derivation output > the derived snapshot carries only the three core fields, never envelope fields [0.09ms] +(pass) derivation output > provenance for the derivation is recorded outside the snapshot [0.07ms] +(pass) derivation output > derivation is deterministic [0.14ms] +(pass) derivation output > the snapshot does not alias the envelope arrays [0.08ms] +(pass) the mapping is lossy by design > explicit-empty and annotation-absent both map to an empty paths array [0.10ms] +(pass) the mapping is lossy by design > an all-annotation-absent envelope derives a snapshot with no paths anywhere [0.09ms] + +packages/catalog-envelope/test/identity.test.ts: +(pass) staleness is exact inequality > the stale fixture passes the digest check cleanly first [0.18ms] +(pass) staleness is exact inequality > a different revision for the same repository is stale [0.09ms] +(pass) staleness is exact inequality > an exactly equal revision is ok [0.05ms] +(pass) staleness is exact inequality > inequality is symmetric — direction is never inferred [0.09ms] +(pass) staleness is exact inequality > a lexicographically smaller revision is just as stale as a larger one [0.09ms] +(pass) staleness is exact inequality > with no expectation configured, the outcome is not-configured rather than ok [0.05ms] +(pass) staleness is exact inequality > the comparison is declared on the result and names no ordering [0.04ms] +(pass) staleness is exact inequality > an expectation configured for another repository yields no staleness verdict [0.05ms] +(pass) staleness is exact inequality > the scoping does not weaken the check for the repository it is about [0.04ms] +(pass) staleness is exact inequality > admission refuses the stale fixture at the staleness stage, not the digest stage [0.08ms] +(pass) repository identity mismatch is a rejection > the wrong-repository fixture passes the digest check cleanly first [0.04ms] +(pass) repository identity mismatch is a rejection > a different repository id is a mismatch [0.04ms] +(pass) repository identity mismatch is a rejection > the expected repository id is ok [0.07ms] +(pass) repository identity mismatch is a rejection > with no expectation configured, the outcome is not-configured rather than ok [0.05ms] +(pass) repository identity mismatch is a rejection > admission refuses at the identity stage, after staleness has passed [0.06ms] +(pass) repository identity mismatch is a rejection > the same refusal holds with no revision expectation at all [0.06ms] +(pass) repository isolation is acceptance, not rejection > a valid envelope from a different repository is admitted on its own terms [0.06ms] +(pass) repository isolation is acceptance, not rejection > a query scoped to one repository returns only that repository's entities [0.14ms] +(pass) repository isolation is acceptance, not rejection > no entity from one repository ever leaks into the other repository's result [0.11ms] +(pass) repository isolation is acceptance, not rejection > a query scoped to a repository nobody loaded returns nothing and says so [0.08ms] +(pass) repository isolation is acceptance, not rejection > neither envelope is rejected by the query, and both remain independently valid [0.11ms] + +packages/catalog-envelope/test/digest.test.ts: +(pass) canonicalization > the digest field itself is excluded and every other field is included [0.10ms] +(pass) canonicalization > object keys are sorted at every nesting level, arrays are not [0.05ms] +(pass) canonicalization > the digest is 64 lowercase hex over the UTF-8 bytes of the canonical form [0.09ms] +(pass) canonicalization > reordering the keys of an envelope does not change its digest [0.08ms] +(pass) canonicalization > recomputation is stable across repeated runs [0.10ms] +(pass) digest verification > the valid fixture matches [0.08ms] +(pass) digest verification > the tampered fixture is rejected, and the mismatch is named [0.07ms] +(pass) digest verification > admission refuses the tampered fixture at the digest stage, not earlier [0.07ms] +(pass) digest verification > a single flipped character anywhere in the payload is detected [0.07ms] +(pass) digest verification > the declared digest is never trusted unconditionally [0.07ms] +(pass) the guarantee scope travels with the result > every digest result carries the scope statement [0.11ms] +(pass) the guarantee scope travels with the result > the scope statement names both limits and claims neither strength [0.01ms] + + 44 pass + 0 fail + 131 expect() calls +Ran 44 tests across 3 files. [51.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-staleness/README.md b/specs/010-catalog-backstage/evidence/negative-cases/consumer-staleness/README.md new file mode 100644 index 00000000..8b3d09e1 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-staleness/README.md @@ -0,0 +1,96 @@ +# Negative case: staleness implemented as an ordering comparison + +**Task**: T032 · **Discharges**: FR-047 +**Contract**: [`snapshot-envelope.md`](../../../../009-catalog-binding-viability/contracts/snapshot-envelope.md) §4 +**Observed against**: the Phase C working tree, `packages/catalog-envelope/src/` otherwise unmodified +**Tools**: Bun 1.3.14 +**Permanent automated case**: `packages/catalog-envelope/test/identity.test.ts` + +## Why this particular wrong implementation + +`snapshot-envelope.md` §4 requires staleness to be **exact inequality** of +revision. A commit SHA is an opaque identifier; it carries no ordering, and +recovering one would need git-ancestry data that is out of scope. So "stale" +means *not exactly the configured expected-current revision* — never "older +than", "behind", or "superseded by". + +The dangerous implementation is not a missing check. It is a **present** check +that compares two hex strings with `<`. That produces an ordering, the ordering +is meaningless, and roughly half of all stale envelopes are accepted while the +other half are rejected — so the check looks like it works. + +## Input + +[`ordering-comparison-instead-of-inequality.patch`](./ordering-comparison-instead-of-inequality.patch): + +```diff +- if (declaredRevision !== expectedRevision) { ++ if (declaredRevision < expectedRevision) { +``` + +`stale.json` declares revision `f0e9d8c7…` against an expected-current of +`1e0f3c9a…`. Under exact inequality it is stale. Under lexicographic ordering +`f0e9…` sorts *after* `1e0f…`, so the mutated implementation calls it current +and returns `ok`. + +Its digest is recomputed over its own actual (mutated-revision) content, so it +passes the digest check cleanly — the rejection under the correct +implementation is attributable specifically to staleness and never to a +coincidental digest failure (`snapshot-envelope.md` §4; User Story 7 scenario 3). +That isolation is itself asserted, by `the stale fixture passes the digest check +cleanly first`. + +## Observed + +Command: `bun test packages/catalog-envelope/test/{digest,identity,derive}.test.ts` · +Exit **1** · **38 pass, 6 fail** · +[`ordering-comparison-instead-of-inequality.observed.txt`](./ordering-comparison-instead-of-inequality.observed.txt) + +Failing: + +- `staleness is exact inequality > a different revision for the same repository is stale` +- `staleness is exact inequality > inequality is symmetric — direction is never inferred` +- `staleness is exact inequality > a lexicographically smaller revision is just as stale as a larger one` +- `staleness is exact inequality > the scoping does not weaken the check for the repository it is about` +- `staleness is exact inequality > admission refuses the stale fixture at the staleness stage, not the digest stage` +- `admission runs every check in order before permitting derivation > the refusal stage is named for every fixture that cannot be admitted` + +The two that matter most are the symmetry case and the +lexicographically-smaller case. Each compares the *same pair* of revisions in +the opposite direction, and both must produce `stale-revision`. An ordering +implementation can only ever fail one of those two, which is exactly what makes +the pair able to detect it. + +## A note on scoping, recorded because it changed the implementation + +`snapshot-envelope.md` §4 configures the expected-current revision **for a given +repository ID**. `spec.md` FR-046 fixes the evaluation order as digest → staleness +→ repository identity, so staleness runs *before* an identity mismatch has been +named. + +An unscoped staleness check therefore refuses `wrong-repository.json` as **stale** +rather than as **misidentified** — the right verdict category for the wrong +reason, and the §5/§6 conflation the contract warns against, wearing a staleness +label. The implementation takes the expectation's repository id and returns +`not-applicable-different-repository` when the envelope is about another +repository, leaving the verdict to the identity check that follows. Asserted by +`an expectation configured for another repository yields no staleness verdict`. + +## Restored + +[`restored.observed.txt`](./restored.observed.txt) — **44 pass, 0 fail**, exit 0. + +## Reproducing + +```bash +git apply specs/010-catalog-backstage/evidence/negative-cases/consumer-staleness/ordering-comparison-instead-of-inequality.patch +bun test packages/catalog-envelope/test/identity.test.ts # expect exit 1 +git checkout -- packages/catalog-envelope/src/identity/staleness.ts +bun test packages/catalog-envelope/test/identity.test.ts # expect exit 0 +``` + +## Standing constraints + +Synthetic fixtures only; no external adopter. ADR-0014 **rung 1 only** — not +reference-verified (rung 2), not externally validated (rung 3). Maintainer-owned +observation, which is not external, third-party, or community validation. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-staleness/ordering-comparison-instead-of-inequality.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-staleness/ordering-comparison-instead-of-inequality.observed.txt new file mode 100644 index 00000000..59912225 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-staleness/ordering-comparison-instead-of-inequality.observed.txt @@ -0,0 +1,147 @@ +# Observed: staleness implemented as a lexicographic ordering comparison + +$ bun test packages/catalog-envelope/test/digest.test.ts packages/catalog-envelope/test/identity.test.ts packages/catalog-envelope/test/derive.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/derive.test.ts: +(pass) admission runs every check in order before permitting derivation > the valid fixture is admitted with all four verdicts recorded [1.87ms] +57 | ['wrong-repository.json', 'repository-identity', 'repository-identity-mismatch'], +58 | ]; +59 | +60 | const observed = expected.map(([fixture]) => { +61 | const result = admitEnvelope(fixtureText(fixture), ADMIT_OPTIONS_A); +62 | if (result.outcome !== 'refused') throw new Error(`${fixture} was admitted`); + ^ +error: stale.json was admitted + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/derive.test.ts:62:82) + at map (1:11) + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/derive.test.ts:60:31) +(fail) admission runs every check in order before permitting derivation > the refusal stage is named for every fixture that cannot be admitted [0.51ms] +(pass) admission runs every check in order before permitting derivation > the all-annotation-absent contrast case is admitted, not refused [0.08ms] +(pass) admission runs every check in order before permitting derivation > admission without a configured revision or repository still runs digest [0.07ms] +(pass) derivation output > every entity maps to a CatalogSnapshotEntity of id, refs and paths [0.12ms] +(pass) derivation output > the derived snapshot carries only the three core fields, never envelope fields [0.08ms] +(pass) derivation output > provenance for the derivation is recorded outside the snapshot [0.08ms] +(pass) derivation output > derivation is deterministic [0.23ms] +(pass) derivation output > the snapshot does not alias the envelope arrays [0.08ms] +(pass) the mapping is lossy by design > explicit-empty and annotation-absent both map to an empty paths array [0.10ms] +(pass) the mapping is lossy by design > an all-annotation-absent envelope derives a snapshot with no paths anywhere [0.08ms] + +packages/catalog-envelope/test/identity.test.ts: +(pass) staleness is exact inequality > the stale fixture passes the digest check cleanly first [0.16ms] +49 | }); +50 | +51 | test('a different revision for the same repository is stale', () => { +52 | const result = checkStaleness(validated('stale.json'), REVISION_A); +53 | +54 | expect(result.outcome).toBe('stale-revision'); + ^ +error: expect(received).toBe(expected) + +Expected: "stale-revision" +Received: "ok" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/identity.test.ts:54:28) +(fail) staleness is exact inequality > a different revision for the same repository is stale [0.13ms] +(pass) staleness is exact inequality > an exactly equal revision is ok [0.05ms] +68 | // expected and which is declared produces the same verdict. A chronological +69 | // implementation would call one of these two directions acceptable. +70 | const forwards = checkStaleness(validated('stale.json'), REVISION_A); +71 | const backwards = checkStaleness(validated('valid.json'), REVISION_A_STALE); +72 | +73 | expect(forwards.outcome).toBe('stale-revision'); + ^ +error: expect(received).toBe(expected) + +Expected: "stale-revision" +Received: "ok" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/identity.test.ts:73:30) +(fail) staleness is exact inequality > inequality is symmetric — direction is never inferred [0.12ms] +74 | expect(backwards.outcome).toBe('stale-revision'); +75 | }); +76 | +77 | test('a lexicographically smaller revision is just as stale as a larger one', () => { +78 | for (const expected of ['0'.repeat(40), 'f'.repeat(40)]) { +79 | expect(checkStaleness(validated('valid.json'), expected).outcome).toBe('stale-revision'); + ^ +error: expect(received).toBe(expected) + +Expected: "stale-revision" +Received: "ok" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/identity.test.ts:79:73) +(fail) staleness is exact inequality > a lexicographically smaller revision is just as stale as a larger one [0.10ms] +(pass) staleness is exact inequality > with no expectation configured, the outcome is not-configured rather than ok [0.05ms] +(pass) staleness is exact inequality > the comparison is declared on the result and names no ordering [0.05ms] +(pass) staleness is exact inequality > an expectation configured for another repository yields no staleness verdict [0.06ms] +110 | }); +111 | +112 | test('the scoping does not weaken the check for the repository it is about', () => { +113 | // Same call shape, same repository — still stale. +114 | const result = checkStaleness(validated('stale.json'), REVISION_A, REPOSITORY_A); +115 | expect(result.outcome).toBe('stale-revision'); + ^ +error: expect(received).toBe(expected) + +Expected: "stale-revision" +Received: "ok" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/identity.test.ts:115:28) +(fail) staleness is exact inequality > the scoping does not weaken the check for the repository it is about [0.06ms] +116 | }); +117 | +118 | test('admission refuses the stale fixture at the staleness stage, not the digest stage', () => { +119 | const result = admitEnvelope(fixtureText('stale.json'), ADMIT_OPTIONS_A); +120 | +121 | expect(result.outcome).toBe('refused'); + ^ +error: expect(received).toBe(expected) + +Expected: "refused" +Received: "admitted" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/identity.test.ts:121:28) +(fail) staleness is exact inequality > admission refuses the stale fixture at the staleness stage, not the digest stage [0.09ms] +(pass) repository identity mismatch is a rejection > the wrong-repository fixture passes the digest check cleanly first [0.05ms] +(pass) repository identity mismatch is a rejection > a different repository id is a mismatch [0.04ms] +(pass) repository identity mismatch is a rejection > the expected repository id is ok [0.04ms] +(pass) repository identity mismatch is a rejection > with no expectation configured, the outcome is not-configured rather than ok [0.04ms] +(pass) repository identity mismatch is a rejection > admission refuses at the identity stage, after staleness has passed [0.06ms] +(pass) repository identity mismatch is a rejection > the same refusal holds with no revision expectation at all [0.05ms] +(pass) repository isolation is acceptance, not rejection > a valid envelope from a different repository is admitted on its own terms [0.06ms] +(pass) repository isolation is acceptance, not rejection > a query scoped to one repository returns only that repository's entities [0.14ms] +(pass) repository isolation is acceptance, not rejection > no entity from one repository ever leaks into the other repository's result [0.10ms] +(pass) repository isolation is acceptance, not rejection > a query scoped to a repository nobody loaded returns nothing and says so [0.08ms] +(pass) repository isolation is acceptance, not rejection > neither envelope is rejected by the query, and both remain independently valid [0.09ms] + +packages/catalog-envelope/test/digest.test.ts: +(pass) canonicalization > the digest field itself is excluded and every other field is included [0.08ms] +(pass) canonicalization > object keys are sorted at every nesting level, arrays are not [0.05ms] +(pass) canonicalization > the digest is 64 lowercase hex over the UTF-8 bytes of the canonical form [0.09ms] +(pass) canonicalization > reordering the keys of an envelope does not change its digest [0.08ms] +(pass) canonicalization > recomputation is stable across repeated runs [0.11ms] +(pass) digest verification > the valid fixture matches [0.07ms] +(pass) digest verification > the tampered fixture is rejected, and the mismatch is named [0.07ms] +(pass) digest verification > admission refuses the tampered fixture at the digest stage, not earlier [0.06ms] +(pass) digest verification > a single flipped character anywhere in the payload is detected [0.07ms] +(pass) digest verification > the declared digest is never trusted unconditionally [0.06ms] +(pass) the guarantee scope travels with the result > every digest result carries the scope statement [0.11ms] +(pass) the guarantee scope travels with the result > the scope statement names both limits and claims neither strength [0.01ms] + +6 tests failed: +(fail) admission runs every check in order before permitting derivation > the refusal stage is named for every fixture that cannot be admitted [0.51ms] +(fail) staleness is exact inequality > a different revision for the same repository is stale [0.13ms] +(fail) staleness is exact inequality > inequality is symmetric — direction is never inferred [0.12ms] +(fail) staleness is exact inequality > a lexicographically smaller revision is just as stale as a larger one [0.10ms] +(fail) staleness is exact inequality > the scoping does not weaken the check for the repository it is about [0.06ms] +(fail) staleness is exact inequality > admission refuses the stale fixture at the staleness stage, not the digest stage [0.09ms] + + 38 pass + 6 fail + 121 expect() calls +Ran 44 tests across 3 files. [53.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-staleness/ordering-comparison-instead-of-inequality.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-staleness/ordering-comparison-instead-of-inequality.patch new file mode 100644 index 00000000..e3b37013 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-staleness/ordering-comparison-instead-of-inequality.patch @@ -0,0 +1,13 @@ +diff --git a/packages/catalog-envelope/src/identity/staleness.ts b/packages/catalog-envelope/src/identity/staleness.ts +index 8a0270b..f9d474f 100644 +--- a/packages/catalog-envelope/src/identity/staleness.ts ++++ b/packages/catalog-envelope/src/identity/staleness.ts +@@ -106,7 +106,7 @@ export function checkStaleness( + }; + } + +- if (declaredRevision !== expectedRevision) { ++ if (declaredRevision < expectedRevision) { + return { + expectedRevision, + declaredRevision, diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-staleness/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-staleness/restored.observed.txt new file mode 100644 index 00000000..3022b0e8 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-staleness/restored.observed.txt @@ -0,0 +1,63 @@ +# Restored: every check present + +$ bun test packages/catalog-envelope/test/digest.test.ts packages/catalog-envelope/test/identity.test.ts packages/catalog-envelope/test/derive.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/derive.test.ts: +(pass) admission runs every check in order before permitting derivation > the valid fixture is admitted with all four verdicts recorded [1.89ms] +(pass) admission runs every check in order before permitting derivation > the refusal stage is named for every fixture that cannot be admitted [0.54ms] +(pass) admission runs every check in order before permitting derivation > the all-annotation-absent contrast case is admitted, not refused [0.07ms] +(pass) admission runs every check in order before permitting derivation > admission without a configured revision or repository still runs digest [0.07ms] +(pass) derivation output > every entity maps to a CatalogSnapshotEntity of id, refs and paths [0.10ms] +(pass) derivation output > the derived snapshot carries only the three core fields, never envelope fields [0.08ms] +(pass) derivation output > provenance for the derivation is recorded outside the snapshot [0.06ms] +(pass) derivation output > derivation is deterministic [0.13ms] +(pass) derivation output > the snapshot does not alias the envelope arrays [0.06ms] +(pass) the mapping is lossy by design > explicit-empty and annotation-absent both map to an empty paths array [0.10ms] +(pass) the mapping is lossy by design > an all-annotation-absent envelope derives a snapshot with no paths anywhere [0.08ms] + +packages/catalog-envelope/test/identity.test.ts: +(pass) staleness is exact inequality > the stale fixture passes the digest check cleanly first [0.20ms] +(pass) staleness is exact inequality > a different revision for the same repository is stale [0.12ms] +(pass) staleness is exact inequality > an exactly equal revision is ok [0.09ms] +(pass) staleness is exact inequality > inequality is symmetric — direction is never inferred [0.10ms] +(pass) staleness is exact inequality > a lexicographically smaller revision is just as stale as a larger one [0.14ms] +(pass) staleness is exact inequality > with no expectation configured, the outcome is not-configured rather than ok [0.06ms] +(pass) staleness is exact inequality > the comparison is declared on the result and names no ordering [0.04ms] +(pass) staleness is exact inequality > an expectation configured for another repository yields no staleness verdict [0.06ms] +(pass) staleness is exact inequality > the scoping does not weaken the check for the repository it is about [0.04ms] +(pass) staleness is exact inequality > admission refuses the stale fixture at the staleness stage, not the digest stage [0.09ms] +(pass) repository identity mismatch is a rejection > the wrong-repository fixture passes the digest check cleanly first [0.04ms] +(pass) repository identity mismatch is a rejection > a different repository id is a mismatch [0.04ms] +(pass) repository identity mismatch is a rejection > the expected repository id is ok [0.05ms] +(pass) repository identity mismatch is a rejection > with no expectation configured, the outcome is not-configured rather than ok [0.04ms] +(pass) repository identity mismatch is a rejection > admission refuses at the identity stage, after staleness has passed [0.05ms] +(pass) repository identity mismatch is a rejection > the same refusal holds with no revision expectation at all [0.06ms] +(pass) repository isolation is acceptance, not rejection > a valid envelope from a different repository is admitted on its own terms [0.05ms] +(pass) repository isolation is acceptance, not rejection > a query scoped to one repository returns only that repository's entities [0.12ms] +(pass) repository isolation is acceptance, not rejection > no entity from one repository ever leaks into the other repository's result [0.09ms] +(pass) repository isolation is acceptance, not rejection > a query scoped to a repository nobody loaded returns nothing and says so [0.07ms] +(pass) repository isolation is acceptance, not rejection > neither envelope is rejected by the query, and both remain independently valid [0.10ms] + +packages/catalog-envelope/test/digest.test.ts: +(pass) canonicalization > the digest field itself is excluded and every other field is included [0.12ms] +(pass) canonicalization > object keys are sorted at every nesting level, arrays are not [0.06ms] +(pass) canonicalization > the digest is 64 lowercase hex over the UTF-8 bytes of the canonical form [0.10ms] +(pass) canonicalization > reordering the keys of an envelope does not change its digest [0.08ms] +(pass) canonicalization > recomputation is stable across repeated runs [0.10ms] +(pass) digest verification > the valid fixture matches [0.08ms] +(pass) digest verification > the tampered fixture is rejected, and the mismatch is named [0.07ms] +(pass) digest verification > admission refuses the tampered fixture at the digest stage, not earlier [0.06ms] +(pass) digest verification > a single flipped character anywhere in the payload is detected [0.07ms] +(pass) digest verification > the declared digest is never trusted unconditionally [0.06ms] +(pass) the guarantee scope travels with the result > every digest result carries the scope statement [0.11ms] +(pass) the guarantee scope travels with the result > the scope statement names both limits and claims neither strength [0.02ms] + + 44 pass + 0 fail + 131 expect() calls +Ran 44 tests across 3 files. [50.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/README.md b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/README.md new file mode 100644 index 00000000..49e06b34 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/README.md @@ -0,0 +1,106 @@ +# Negative case: each of the five consumer validation steps, deleted in turn + +**Task**: T029 · **Supplies**: the ADR-0016 observations behind FR-045 and SC-014 +**Contract**: [`snapshot-envelope.md`](../../../../009-catalog-binding-viability/contracts/snapshot-envelope.md) §2, §7 +**Observed against**: worktree at `99ba8d2500eaf37625ea164f66b4a17870e40dad` plus the +Phase C working tree, `packages/catalog-envelope/src/validate/index.ts` otherwise unmodified +**Tools**: Bun 1.3.14 +**Permanent automated case**: `packages/catalog-envelope/test/validate-steps.test.ts` + +## Why the fixtures alone were not treated as the observation + +The five malformed fixtures are permanent negative inputs, and driving them +through the validator shows five distinct rejections. That establishes the +fixtures reject — it does **not** establish that each *step* is load-bearing. A +validator that rejected everything at step 2 would still produce a rejection for +every fixture. + +So the observation constructed here is one level up: **each of the five steps was +deleted from the validator in turn**, the suite was run, and the failure was +captured verbatim. What that proves is the thing ADR-0016 asks for — that +removing a check makes a test go red, and therefore that the green test is +reporting something it actually looked at. + +## The five reasons, as emitted + +Captured from the restored (unmutated) validator: + +| Fixture | Step | `reason` | `detail` | +|---|---|---|---| +| `malformed-invalid-json.json` | 1 | `invalid-json` | `envelope does not parse as JSON: SyntaxError: JSON Parse error: Unterminated string` | +| `malformed-missing-or-wrong-field.json` | 2 | `missing-or-wrong-required-field` | `entities[1].identity.allRefs is not a string array` | +| `malformed-unrecognized.json` | 3 | `unrecognized-schema-or-dialect-or-capability` | `globDialect.engine is "minimatch", expected "picomatch"` | +| `malformed-missing-source-digest.json` | 4 | `missing-source-digest` | `sources[0] (catalog-info.yaml) declares no digest` | +| `malformed-identity-only.json` | 5 | `identity-only-true` | `completeness.identityOnly is true, so this envelope is partial/identity-only and unusable for path-ownership matching` | + +Each is rejected at **its own** step — none earlier. The `examined.stepsReached` +field records the highest step reached, and it equals the failing step in every +row, so "rejected for the right reason" is asserted rather than assumed. + +Two acceptance rows belong here as well, because a rejection table on its own +cannot show that the validator ever accepts anything: + +| Fixture | Outcome | Why it matters | +|---|---|---| +| `valid.json` | **valid**, `stepsReached: 5`, `entityRecordsInspected: 3`, `sourcesVerified: ["catalog-info.yaml"]` | The positive control | +| `all-annotation-absent.json` | **valid** | `snapshot-envelope.md` §7 row 1b. Every entity is `annotation-absent` and `identityOnly` is `false`. Step 5 reads the boolean and **never** scans the ownership-state distribution; rejecting here would be the specific bug step 5's wording exists to prevent | + +## The five observations + +Each row: apply the patch, run `bun test packages/catalog-envelope/test/validate-steps.test.ts`, +capture, revert. + +| Deleted step | Patch | Observed | Result | +|---|---|---|---| +| 1 — valid JSON | [`step-1-json-parse-deleted.patch`](./step-1-json-parse-deleted.patch) | [`.observed.txt`](./step-1-json-parse-deleted.observed.txt) | **40 pass, 2 fail**, exit 1 | +| 2 — complete shape at every nesting level | [`step-2-entity-shape-deleted.patch`](./step-2-entity-shape-deleted.patch) | [`.observed.txt`](./step-2-entity-shape-deleted.observed.txt) | **33 pass, 9 fail**, exit 1 | +| 3 — frozen matcher contract by exact value | [`step-3-dialect-exact-value-deleted.patch`](./step-3-dialect-exact-value-deleted.patch) | [`.observed.txt`](./step-3-dialect-exact-value-deleted.observed.txt) | **40 pass, 2 fail**, exit 1 | +| 4 — every source digest present and matching | [`step-4-source-digest-deleted.patch`](./step-4-source-digest-deleted.patch) | [`.observed.txt`](./step-4-source-digest-deleted.observed.txt) | **35 pass, 7 fail**, exit 1 | +| 5 — `completeness.identityOnly === false` | [`step-5-identity-only-deleted.patch`](./step-5-identity-only-deleted.patch) | [`.observed.txt`](./step-5-identity-only-deleted.observed.txt) | **39 pass, 3 fail**, exit 1 | + +Restored: [`restored.observed.txt`](./restored.observed.txt) — **42 pass, 0 fail**, exit 0. + +### What the failure sets show, beyond "it went red" + +The interesting part is *which* tests failed, because it confirms each deletion +was localized rather than breaking everything: + +- **Step 1 deleted.** Only the step-1 case and the consolidated five-reason case + fail. The other four fixtures still reject at their own steps. The failure mode + is a thrown `SyntaxError` escaping `validateEnvelope`, not a wrong rejection — + visible in the captured stack. +- **Step 2 deleted** (entity-record shape check short-circuited to `undefined`). + Nine fail: the step-2 fixture, the consolidated case, and the seven nested + entity-level malformations. Every one of those seven is a case that a + top-level-only shape check would have let through, so this is also the + observation that the "at **every** nesting level" clause is real. +- **Step 3 deleted** (the `globDialect.engine` exact-value comparison). Two fail. + The seven other step-3 variants — `schemaVersion`, `globDialect.version`, the + three `options` flags, and the three `capabilities` shapes — still pass, + because each is a separate comparison. That is the evidence that step 3 is not + one check wearing three names. +- **Step 4 deleted** (the source-digest loop skipped). Seven fail, including + `the valid fixture passes all five steps` — because `sourcesVerified` becomes + empty, and the positive control asserts on the specific list of sources + actually opened. A validator that silently verified nothing would otherwise + render identically to one that verified everything. +- **Step 5 deleted.** Three fail, including the ordering assertion that step 5 is + reached only after every source digest is verified. + +## Reproducing + +```bash +git apply specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-3-dialect-exact-value-deleted.patch +bun test packages/catalog-envelope/test/validate-steps.test.ts # expect exit 1 +git checkout -- packages/catalog-envelope/src/validate/index.ts +bun test packages/catalog-envelope/test/validate-steps.test.ts # expect exit 0 +``` + +## Standing constraints + +Every fixture driven here is **synthetic and hand-authored**; no external adopter +is involved, exactly as `snapshot-envelope.md` §7 requires. These are mechanical, +offline generator/consumer-boundary properties. ADR-0014 **rung 1 only** — nothing +here is reference-verified (rung 2) or externally validated (rung 3), and every +observation is maintainer-owned, which is not external, third-party, or community +validation. diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/restored.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/restored.observed.txt new file mode 100644 index 00000000..60bda6da --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/restored.observed.txt @@ -0,0 +1,57 @@ +# Restored: all five steps present + +$ bun test packages/catalog-envelope/test/validate-steps.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/validate-steps.test.ts: +(pass) five ordered validation steps > the reason-to-step mapping is 1:1 and closed [0.04ms] +(pass) five ordered validation steps > malformed-invalid-json.json is rejected at step 1 as invalid-json [0.20ms] +(pass) five ordered validation steps > malformed-missing-or-wrong-field.json is rejected at step 2 as missing-or-wrong-required-field [0.37ms] +(pass) five ordered validation steps > malformed-unrecognized.json is rejected at step 3 as unrecognized-schema-or-dialect-or-capability [0.07ms] +(pass) five ordered validation steps > malformed-missing-source-digest.json is rejected at step 4 as missing-source-digest [0.06ms] +(pass) five ordered validation steps > malformed-identity-only.json is rejected at step 5 as identity-only-true [0.13ms] +(pass) five ordered validation steps > the five rejections are five distinct reasons at five distinct steps [0.19ms] +(pass) five ordered validation steps > step 4 is reached only after steps 2 and 3 have passed [0.05ms] +(pass) five ordered validation steps > step 5 is reached only after every source digest has been verified [0.05ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an unrecognized schemaVersion at step 3 [0.06ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects a globDialect.version other than 4.0.5 at step 3 [0.04ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.dot true at step 3 [0.05ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.nocase true at step 3 [0.05ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.nonegate false at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an empty capabilities array at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an extra capability at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects a different single capability at step 3 [0.03ms] +(pass) step 4 rejects a mismatching digest as well as a missing one > a source digest that does not match the actual bytes is rejected at step 4 [0.06ms] +(pass) step 4 rejects a mismatching digest as well as a missing one > a source whose file cannot be read is rejected at step 4, naming the path [0.19ms] +(pass) step 5 reads the boolean and nothing else > an envelope whose entities are all annotation-absent is accepted [0.06ms] +(pass) step 5 reads the boolean and nothing else > the valid fixture passes all five steps [0.05ms] +(pass) step 2 checks every nesting level > rejects repository.revision missing at step 2 [0.05ms] +(pass) step 2 checks every nesting level > rejects globDialect.options.nocase not a boolean at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects completeness.wholeCatalog not a boolean at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects entities[0].derivedPaths not a string array at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects entities[0].sourceDocument.documentIndexInFile not an integer at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects entities[2].ownershipState not recognized at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects entities[0].provenance empty at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects entities[0].identity.allRefs empty at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an entity record carrying a sixth field at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects a flatter canonicalId/refs/paths triple at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an unrecognized top-level field at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects sources not an array at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an envelope missing schemaVersion at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects an envelope missing repository at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing generatorVersion at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing globDialect at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing capabilities at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing completeness at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing sources at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing entities at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing digest at step 2 [0.02ms] + + 42 pass + 0 fail + 163 expect() calls +Ran 42 tests across 1 file. [47.00ms] + +exit 0 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-1-json-parse-deleted.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-1-json-parse-deleted.observed.txt new file mode 100644 index 00000000..17c273df --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-1-json-parse-deleted.observed.txt @@ -0,0 +1,83 @@ +# Observed: step 1 (valid JSON) deleted + +$ bun test packages/catalog-envelope/test/validate-steps.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/validate-steps.test.ts: +(pass) five ordered validation steps > the reason-to-step mapping is 1:1 and closed [0.04ms] +465 | * +466 | * Step 1 is here and nowhere else: an envelope that does not parse is rejected +467 | * before any structural claim is made about it. +468 | */ +469 | export function validateEnvelope(text: string, options: ValidateOptions): EnvelopeValidationResult { +470 | const parsed: unknown = JSON.parse(text); + ^ +SyntaxError: JSON Parse error: Unterminated string + at validateEnvelope (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/src/validate/index.ts:470:32) + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:85:22) +(fail) five ordered validation steps > malformed-invalid-json.json is rejected at step 1 as invalid-json [1.02ms] +(pass) five ordered validation steps > malformed-missing-or-wrong-field.json is rejected at step 2 as missing-or-wrong-required-field [0.64ms] +(pass) five ordered validation steps > malformed-unrecognized.json is rejected at step 3 as unrecognized-schema-or-dialect-or-capability [0.11ms] +(pass) five ordered validation steps > malformed-missing-source-digest.json is rejected at step 4 as missing-source-digest [0.14ms] +(pass) five ordered validation steps > malformed-identity-only.json is rejected at step 5 as identity-only-true [0.36ms] +465 | * +466 | * Step 1 is here and nowhere else: an envelope that does not parse is rejected +467 | * before any structural claim is made about it. +468 | */ +469 | export function validateEnvelope(text: string, options: ValidateOptions): EnvelopeValidationResult { +470 | const parsed: unknown = JSON.parse(text); + ^ +SyntaxError: JSON Parse error: Unterminated string + at validateEnvelope (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/src/validate/index.ts:470:32) + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:103:22) + at map (1:11) + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:102:28) +(fail) five ordered validation steps > the five rejections are five distinct reasons at five distinct steps [0.08ms] +(pass) five ordered validation steps > step 4 is reached only after steps 2 and 3 have passed [0.06ms] +(pass) five ordered validation steps > step 5 is reached only after every source digest has been verified [0.07ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an unrecognized schemaVersion at step 3 [0.11ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects a globDialect.version other than 4.0.5 at step 3 [0.06ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.dot true at step 3 [0.06ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.nocase true at step 3 [0.05ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.nonegate false at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an empty capabilities array at step 3 [0.04ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an extra capability at step 3 [0.04ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects a different single capability at step 3 [0.04ms] +(pass) step 4 rejects a mismatching digest as well as a missing one > a source digest that does not match the actual bytes is rejected at step 4 [0.07ms] +(pass) step 4 rejects a mismatching digest as well as a missing one > a source whose file cannot be read is rejected at step 4, naming the path [0.19ms] +(pass) step 5 reads the boolean and nothing else > an envelope whose entities are all annotation-absent is accepted [0.13ms] +(pass) step 5 reads the boolean and nothing else > the valid fixture passes all five steps [0.10ms] +(pass) step 2 checks every nesting level > rejects repository.revision missing at step 2 [0.08ms] +(pass) step 2 checks every nesting level > rejects globDialect.options.nocase not a boolean at step 2 [0.05ms] +(pass) step 2 checks every nesting level > rejects completeness.wholeCatalog not a boolean at step 2 [0.06ms] +(pass) step 2 checks every nesting level > rejects entities[0].derivedPaths not a string array at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects entities[0].sourceDocument.documentIndexInFile not an integer at step 2 [0.05ms] +(pass) step 2 checks every nesting level > rejects entities[2].ownershipState not recognized at step 2 [0.05ms] +(pass) step 2 checks every nesting level > rejects entities[0].provenance empty at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects entities[0].identity.allRefs empty at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an entity record carrying a sixth field at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects a flatter canonicalId/refs/paths triple at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an unrecognized top-level field at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects sources not an array at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing schemaVersion at step 2 [0.05ms] +(pass) step 2 checks every nesting level > rejects an envelope missing repository at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing generatorVersion at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing globDialect at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing capabilities at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing completeness at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an envelope missing sources at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an envelope missing entities at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing digest at step 2 [0.02ms] + +2 tests failed: +(fail) five ordered validation steps > malformed-invalid-json.json is rejected at step 1 as invalid-json [1.02ms] +(fail) five ordered validation steps > the five rejections are five distinct reasons at five distinct steps [0.08ms] + + 40 pass + 2 fail + 155 expect() calls +Ran 42 tests across 1 file. [49.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-1-json-parse-deleted.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-1-json-parse-deleted.patch new file mode 100644 index 00000000..3e1cb5a5 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-1-json-parse-deleted.patch @@ -0,0 +1,17 @@ +diff --git a/packages/catalog-envelope/src/validate/index.ts b/packages/catalog-envelope/src/validate/index.ts +index 0018320..4e0f656 100644 +--- a/packages/catalog-envelope/src/validate/index.ts ++++ b/packages/catalog-envelope/src/validate/index.ts +@@ -467,11 +467,6 @@ export function validateParsedEnvelope( + * before any structural claim is made about it. + */ + export function validateEnvelope(text: string, options: ValidateOptions): EnvelopeValidationResult { +- let parsed: unknown; +- try { +- parsed = JSON.parse(text); +- } catch (error) { +- return reject('invalid-json', `envelope does not parse as JSON: ${String(error)}`, examination(1, 0, [])); +- } ++ const parsed: unknown = JSON.parse(text); + return validateParsedEnvelope(parsed, options); + } diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-2-entity-shape-deleted.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-2-entity-shape-deleted.observed.txt new file mode 100644 index 00000000..1805e319 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-2-entity-shape-deleted.observed.txt @@ -0,0 +1,183 @@ +# Observed: step 2 (complete shape at every nesting level) deleted for entity records + +$ bun test packages/catalog-envelope/test/validate-steps.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/validate-steps.test.ts: +(pass) five ordered validation steps > the reason-to-step mapping is 1:1 and closed [0.03ms] +(pass) five ordered validation steps > malformed-invalid-json.json is rejected at step 1 as invalid-json [0.15ms] +82 | +83 | for (const testCase of CASES) { +84 | test(`${testCase.fixture} is rejected at step ${testCase.step} as ${testCase.reason}`, () => { +85 | const result = validateEnvelope(fixtureText(testCase.fixture), VALIDATE_OPTIONS); +86 | +87 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:87:30) +(fail) five ordered validation steps > malformed-missing-or-wrong-field.json is rejected at step 2 as missing-or-wrong-required-field [0.40ms] +(pass) five ordered validation steps > malformed-unrecognized.json is rejected at step 3 as unrecognized-schema-or-dialect-or-capability [0.04ms] +(pass) five ordered validation steps > malformed-missing-source-digest.json is rejected at step 4 as missing-source-digest [0.04ms] +(pass) five ordered validation steps > malformed-identity-only.json is rejected at step 5 as identity-only-true [0.06ms] + 99 | } +100 | +101 | test('the five rejections are five distinct reasons at five distinct steps', () => { +102 | const observed = CASES.map((testCase) => { +103 | const result = validateEnvelope(fixtureText(testCase.fixture), VALIDATE_OPTIONS); +104 | if (result.outcome !== 'rejected') throw new Error(`${testCase.fixture} was not rejected`); + ^ +error: malformed-missing-or-wrong-field.json was not rejected + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:104:96) + at map (1:11) + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:102:28) +(fail) five ordered validation steps > the five rejections are five distinct reasons at five distinct steps [0.10ms] +(pass) five ordered validation steps > step 4 is reached only after steps 2 and 3 have passed [0.04ms] +(pass) five ordered validation steps > step 5 is reached only after every source digest has been verified [0.05ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an unrecognized schemaVersion at step 3 [0.05ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects a globDialect.version other than 4.0.5 at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.dot true at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.nocase true at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.nonegate false at step 3 [0.04ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an empty capabilities array at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an extra capability at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects a different single capability at step 3 [0.03ms] +(pass) step 4 rejects a mismatching digest as well as a missing one > a source digest that does not match the actual bytes is rejected at step 4 [0.06ms] +(pass) step 4 rejects a mismatching digest as well as a missing one > a source whose file cannot be read is rejected at step 4, naming the path [0.17ms] +(pass) step 5 reads the boolean and nothing else > an envelope whose entities are all annotation-absent is accepted [0.05ms] +(pass) step 5 reads the boolean and nothing else > the valid fixture passes all five steps [0.05ms] +(pass) step 2 checks every nesting level > rejects repository.revision missing at step 2 [0.05ms] +(pass) step 2 checks every nesting level > rejects globDialect.options.nocase not a boolean at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects completeness.wholeCatalog not a boolean at step 2 [0.03ms] +283 | test(`rejects ${variant.name} at step 2`, () => { +284 | const envelope = JSON.parse(fixtureText('valid.json')) as Record; +285 | variant.mutate(envelope); +286 | const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); +287 | +288 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:288:30) +(fail) step 2 checks every nesting level > rejects entities[0].derivedPaths not a string array at step 2 [0.07ms] +283 | test(`rejects ${variant.name} at step 2`, () => { +284 | const envelope = JSON.parse(fixtureText('valid.json')) as Record; +285 | variant.mutate(envelope); +286 | const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); +287 | +288 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:288:30) +(fail) step 2 checks every nesting level > rejects entities[0].sourceDocument.documentIndexInFile not an integer at step 2 [0.07ms] +283 | test(`rejects ${variant.name} at step 2`, () => { +284 | const envelope = JSON.parse(fixtureText('valid.json')) as Record; +285 | variant.mutate(envelope); +286 | const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); +287 | +288 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:288:30) +(fail) step 2 checks every nesting level > rejects entities[2].ownershipState not recognized at step 2 [0.08ms] +283 | test(`rejects ${variant.name} at step 2`, () => { +284 | const envelope = JSON.parse(fixtureText('valid.json')) as Record; +285 | variant.mutate(envelope); +286 | const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); +287 | +288 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:288:30) +(fail) step 2 checks every nesting level > rejects entities[0].provenance empty at step 2 [0.08ms] +283 | test(`rejects ${variant.name} at step 2`, () => { +284 | const envelope = JSON.parse(fixtureText('valid.json')) as Record; +285 | variant.mutate(envelope); +286 | const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); +287 | +288 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:288:30) +(fail) step 2 checks every nesting level > rejects entities[0].identity.allRefs empty at step 2 [0.06ms] +283 | test(`rejects ${variant.name} at step 2`, () => { +284 | const envelope = JSON.parse(fixtureText('valid.json')) as Record; +285 | variant.mutate(envelope); +286 | const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); +287 | +288 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:288:30) +(fail) step 2 checks every nesting level > rejects an entity record carrying a sixth field at step 2 [0.05ms] +283 | test(`rejects ${variant.name} at step 2`, () => { +284 | const envelope = JSON.parse(fixtureText('valid.json')) as Record; +285 | variant.mutate(envelope); +286 | const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); +287 | +288 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:288:30) +(fail) step 2 checks every nesting level > rejects a flatter canonicalId/refs/paths triple at step 2 [0.06ms] +(pass) step 2 checks every nesting level > rejects an unrecognized top-level field at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects sources not an array at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an envelope missing schemaVersion at step 2 [0.05ms] +(pass) step 2 checks every nesting level > rejects an envelope missing repository at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an envelope missing generatorVersion at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing globDialect at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing capabilities at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing completeness at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing sources at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing entities at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing digest at step 2 [0.02ms] + +9 tests failed: +(fail) five ordered validation steps > malformed-missing-or-wrong-field.json is rejected at step 2 as missing-or-wrong-required-field [0.40ms] +(fail) five ordered validation steps > the five rejections are five distinct reasons at five distinct steps [0.10ms] +(fail) step 2 checks every nesting level > rejects entities[0].derivedPaths not a string array at step 2 [0.07ms] +(fail) step 2 checks every nesting level > rejects entities[0].sourceDocument.documentIndexInFile not an integer at step 2 [0.07ms] +(fail) step 2 checks every nesting level > rejects entities[2].ownershipState not recognized at step 2 [0.08ms] +(fail) step 2 checks every nesting level > rejects entities[0].provenance empty at step 2 [0.08ms] +(fail) step 2 checks every nesting level > rejects entities[0].identity.allRefs empty at step 2 [0.06ms] +(fail) step 2 checks every nesting level > rejects an entity record carrying a sixth field at step 2 [0.05ms] +(fail) step 2 checks every nesting level > rejects a flatter canonicalId/refs/paths triple at step 2 [0.06ms] + + 33 pass + 9 fail + 135 expect() calls +Ran 42 tests across 1 file. [48.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-2-entity-shape-deleted.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-2-entity-shape-deleted.patch new file mode 100644 index 00000000..675a3873 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-2-entity-shape-deleted.patch @@ -0,0 +1,12 @@ +diff --git a/packages/catalog-envelope/src/validate/index.ts b/packages/catalog-envelope/src/validate/index.ts +index 0018320..8336f37 100644 +--- a/packages/catalog-envelope/src/validate/index.ts ++++ b/packages/catalog-envelope/src/validate/index.ts +@@ -179,6 +179,7 @@ function examination( + * `test/no-early-read.test.ts` counts these reads and enforces that discipline. + */ + function checkEntityRecord(record: unknown, index: number): string | undefined { ++ return undefined; + if (!isPlainObject(record)) return `entities[${index}] is not an object`; + + const extra = Object.keys(record).filter( diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-3-dialect-exact-value-deleted.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-3-dialect-exact-value-deleted.observed.txt new file mode 100644 index 00000000..d8175ff5 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-3-dialect-exact-value-deleted.observed.txt @@ -0,0 +1,85 @@ +# Observed: step 3 (frozen matcher contract by exact value) deleted for globDialect.engine + +$ bun test packages/catalog-envelope/test/validate-steps.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/validate-steps.test.ts: +(pass) five ordered validation steps > the reason-to-step mapping is 1:1 and closed [0.04ms] +(pass) five ordered validation steps > malformed-invalid-json.json is rejected at step 1 as invalid-json [0.95ms] +(pass) five ordered validation steps > malformed-missing-or-wrong-field.json is rejected at step 2 as missing-or-wrong-required-field [0.40ms] +82 | +83 | for (const testCase of CASES) { +84 | test(`${testCase.fixture} is rejected at step ${testCase.step} as ${testCase.reason}`, () => { +85 | const result = validateEnvelope(fixtureText(testCase.fixture), VALIDATE_OPTIONS); +86 | +87 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:87:30) +(fail) five ordered validation steps > malformed-unrecognized.json is rejected at step 3 as unrecognized-schema-or-dialect-or-capability [0.29ms] +(pass) five ordered validation steps > malformed-missing-source-digest.json is rejected at step 4 as missing-source-digest [0.07ms] +(pass) five ordered validation steps > malformed-identity-only.json is rejected at step 5 as identity-only-true [0.11ms] + 99 | } +100 | +101 | test('the five rejections are five distinct reasons at five distinct steps', () => { +102 | const observed = CASES.map((testCase) => { +103 | const result = validateEnvelope(fixtureText(testCase.fixture), VALIDATE_OPTIONS); +104 | if (result.outcome !== 'rejected') throw new Error(`${testCase.fixture} was not rejected`); + ^ +error: malformed-unrecognized.json was not rejected + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:104:96) + at map (1:11) + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:102:28) +(fail) five ordered validation steps > the five rejections are five distinct reasons at five distinct steps [0.27ms] +(pass) five ordered validation steps > step 4 is reached only after steps 2 and 3 have passed [0.06ms] +(pass) five ordered validation steps > step 5 is reached only after every source digest has been verified [0.06ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an unrecognized schemaVersion at step 3 [0.07ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects a globDialect.version other than 4.0.5 at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.dot true at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.nocase true at step 3 [0.04ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.nonegate false at step 3 [0.05ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an empty capabilities array at step 3 [0.04ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an extra capability at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects a different single capability at step 3 [0.03ms] +(pass) step 4 rejects a mismatching digest as well as a missing one > a source digest that does not match the actual bytes is rejected at step 4 [0.07ms] +(pass) step 4 rejects a mismatching digest as well as a missing one > a source whose file cannot be read is rejected at step 4, naming the path [0.18ms] +(pass) step 5 reads the boolean and nothing else > an envelope whose entities are all annotation-absent is accepted [0.06ms] +(pass) step 5 reads the boolean and nothing else > the valid fixture passes all five steps [0.05ms] +(pass) step 2 checks every nesting level > rejects repository.revision missing at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects globDialect.options.nocase not a boolean at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects completeness.wholeCatalog not a boolean at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects entities[0].derivedPaths not a string array at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects entities[0].sourceDocument.documentIndexInFile not an integer at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects entities[2].ownershipState not recognized at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects entities[0].provenance empty at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects entities[0].identity.allRefs empty at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an entity record carrying a sixth field at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects a flatter canonicalId/refs/paths triple at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an unrecognized top-level field at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects sources not an array at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects an envelope missing schemaVersion at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects an envelope missing repository at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing generatorVersion at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing globDialect at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing capabilities at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an envelope missing completeness at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing sources at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing entities at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing digest at step 2 [0.02ms] + +2 tests failed: +(fail) five ordered validation steps > malformed-unrecognized.json is rejected at step 3 as unrecognized-schema-or-dialect-or-capability [0.29ms] +(fail) five ordered validation steps > the five rejections are five distinct reasons at five distinct steps [0.27ms] + + 40 pass + 2 fail + 156 expect() calls +Ran 42 tests across 1 file. [48.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-3-dialect-exact-value-deleted.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-3-dialect-exact-value-deleted.patch new file mode 100644 index 00000000..b3d0f755 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-3-dialect-exact-value-deleted.patch @@ -0,0 +1,13 @@ +diff --git a/packages/catalog-envelope/src/validate/index.ts b/packages/catalog-envelope/src/validate/index.ts +index 0018320..36934e9 100644 +--- a/packages/catalog-envelope/src/validate/index.ts ++++ b/packages/catalog-envelope/src/validate/index.ts +@@ -363,7 +363,7 @@ export function validateParsedEnvelope( + at3(), + ); + } +- if (globDialect['engine'] !== FROZEN_GLOB_DIALECT.engine) { ++ if (false && globDialect['engine'] !== FROZEN_GLOB_DIALECT.engine) { + return reject( + 'unrecognized-schema-or-dialect-or-capability', + `globDialect.engine is ${JSON.stringify(globDialect['engine'])}, expected ${JSON.stringify(FROZEN_GLOB_DIALECT.engine)}`, diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-4-source-digest-deleted.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-4-source-digest-deleted.observed.txt new file mode 100644 index 00000000..edd5c6b3 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-4-source-digest-deleted.observed.txt @@ -0,0 +1,169 @@ +# Observed: step 4 (every source digest present and matching) deleted + +$ bun test packages/catalog-envelope/test/validate-steps.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/validate-steps.test.ts: +(pass) five ordered validation steps > the reason-to-step mapping is 1:1 and closed [0.52ms] +(pass) five ordered validation steps > malformed-invalid-json.json is rejected at step 1 as invalid-json [0.21ms] +(pass) five ordered validation steps > malformed-missing-or-wrong-field.json is rejected at step 2 as missing-or-wrong-required-field [0.37ms] +(pass) five ordered validation steps > malformed-unrecognized.json is rejected at step 3 as unrecognized-schema-or-dialect-or-capability [0.06ms] +82 | +83 | for (const testCase of CASES) { +84 | test(`${testCase.fixture} is rejected at step ${testCase.step} as ${testCase.reason}`, () => { +85 | const result = validateEnvelope(fixtureText(testCase.fixture), VALIDATE_OPTIONS); +86 | +87 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:87:30) +(fail) five ordered validation steps > malformed-missing-source-digest.json is rejected at step 4 as missing-source-digest [0.14ms] +(pass) five ordered validation steps > malformed-identity-only.json is rejected at step 5 as identity-only-true [0.07ms] + 99 | } +100 | +101 | test('the five rejections are five distinct reasons at five distinct steps', () => { +102 | const observed = CASES.map((testCase) => { +103 | const result = validateEnvelope(fixtureText(testCase.fixture), VALIDATE_OPTIONS); +104 | if (result.outcome !== 'rejected') throw new Error(`${testCase.fixture} was not rejected`); + ^ +error: malformed-missing-source-digest.json was not rejected + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:104:96) + at map (1:11) + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:102:28) +(fail) five ordered validation steps > the five rejections are five distinct reasons at five distinct steps [0.26ms] +112 | test('step 4 is reached only after steps 2 and 3 have passed', () => { +113 | // The missing-source-digest fixture is structurally complete and carries the +114 | // frozen dialect, so it exercises the ordering directly: a validator that +115 | // folded digest-presence into the shape check would reject it at step 2. +116 | const result = validateEnvelope(fixtureText('malformed-missing-source-digest.json'), VALIDATE_OPTIONS); +117 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:117:28) +(fail) five ordered validation steps > step 4 is reached only after steps 2 and 3 have passed [0.08ms] +124 | test('step 5 is reached only after every source digest has been verified', () => { +125 | const result = validateEnvelope(fixtureText('malformed-identity-only.json'), VALIDATE_OPTIONS); +126 | expect(result.outcome).toBe('rejected'); +127 | if (result.outcome !== 'rejected') return; +128 | expect(result.failedStep).toBe(5); +129 | expect(result.examined.sourcesVerified).toEqual(['catalog-info.yaml']); + ^ +error: expect(received).toEqual(expected) + +- [ +- "catalog-info.yaml", +- ] ++ [] + +- Expected - 3 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:129:45) +(fail) five ordered validation steps > step 5 is reached only after every source digest has been verified [0.09ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an unrecognized schemaVersion at step 3 [0.07ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects a globDialect.version other than 4.0.5 at step 3 [0.04ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.dot true at step 3 [0.04ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.nocase true at step 3 [0.04ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.nonegate false at step 3 [0.05ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an empty capabilities array at step 3 [0.04ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an extra capability at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects a different single capability at step 3 [0.03ms] +211 | test('a source digest that does not match the actual bytes is rejected at step 4', () => { +212 | const envelope = JSON.parse(fixtureText('valid.json')) as Record; +213 | (envelope['sources'] as Record[])[0]!['digest'] = 'f'.repeat(64); +214 | const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); +215 | +216 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:216:28) +(fail) step 4 rejects a mismatching digest as well as a missing one > a source digest that does not match the actual bytes is rejected at step 4 [0.07ms] +225 | test('a source whose file cannot be read is rejected at step 4, naming the path', () => { +226 | const envelope = JSON.parse(fixtureText('valid.json')) as Record; +227 | (envelope['sources'] as Record[])[0]!['path'] = 'no-such-descriptor.yaml'; +228 | const result = validateEnvelope(JSON.stringify(envelope), VALIDATE_OPTIONS); +229 | +230 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:230:28) +(fail) step 4 rejects a mismatching digest as well as a missing one > a source whose file cannot be read is rejected at step 4, naming the path [0.07ms] +(pass) step 5 reads the boolean and nothing else > an envelope whose entities are all annotation-absent is accepted [0.04ms] +253 | +254 | expect(result.outcome).toBe('valid'); +255 | if (result.outcome !== 'valid') return; +256 | expect(result.reason).toBeUndefined(); +257 | expect(result.failedStep).toBeUndefined(); +258 | expect(result.examined).toEqual({ + ^ +error: expect(received).toEqual(expected) + + { + "entityRecordsInspected": 3, +- "sourcesVerified": [ +- "catalog-info.yaml", +- ], ++ "sourcesVerified": [], + "stepsReached": 5, + } + +- Expected - 3 ++ Received + 1 + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:258:29) +(fail) step 5 reads the boolean and nothing else > the valid fixture passes all five steps [0.07ms] +(pass) step 2 checks every nesting level > rejects repository.revision missing at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects globDialect.options.nocase not a boolean at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects completeness.wholeCatalog not a boolean at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects entities[0].derivedPaths not a string array at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects entities[0].sourceDocument.documentIndexInFile not an integer at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects entities[2].ownershipState not recognized at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects entities[0].provenance empty at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects entities[0].identity.allRefs empty at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an entity record carrying a sixth field at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects a flatter canonicalId/refs/paths triple at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an unrecognized top-level field at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects sources not an array at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an envelope missing schemaVersion at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects an envelope missing repository at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing generatorVersion at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing globDialect at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing capabilities at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing completeness at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing sources at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an envelope missing entities at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing digest at step 2 [0.02ms] + +7 tests failed: +(fail) five ordered validation steps > malformed-missing-source-digest.json is rejected at step 4 as missing-source-digest [0.14ms] +(fail) five ordered validation steps > the five rejections are five distinct reasons at five distinct steps [0.26ms] +(fail) five ordered validation steps > step 4 is reached only after steps 2 and 3 have passed [0.08ms] +(fail) five ordered validation steps > step 5 is reached only after every source digest has been verified [0.09ms] +(fail) step 4 rejects a mismatching digest as well as a missing one > a source digest that does not match the actual bytes is rejected at step 4 [0.07ms] +(fail) step 4 rejects a mismatching digest as well as a missing one > a source whose file cannot be read is rejected at step 4, naming the path [0.07ms] +(fail) step 5 reads the boolean and nothing else > the valid fixture passes all five steps [0.07ms] + + 35 pass + 7 fail + 147 expect() calls +Ran 42 tests across 1 file. [47.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-4-source-digest-deleted.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-4-source-digest-deleted.patch new file mode 100644 index 00000000..c0378d14 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-4-source-digest-deleted.patch @@ -0,0 +1,13 @@ +diff --git a/packages/catalog-envelope/src/validate/index.ts b/packages/catalog-envelope/src/validate/index.ts +index 0018320..aea7629 100644 +--- a/packages/catalog-envelope/src/validate/index.ts ++++ b/packages/catalog-envelope/src/validate/index.ts +@@ -403,7 +403,7 @@ export function validateParsedEnvelope( + const verified: string[] = []; + const at4 = (): EnvelopeExamination => examination(4, inspected, [...verified]); + +- for (let index = 0; index < sources.length; index += 1) { ++ for (let index = 0; index < 0; index += 1) { + const entry = sources[index] as Record; + const path = entry['path'] as string; + const declared = entry['digest']; diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-5-identity-only-deleted.observed.txt b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-5-identity-only-deleted.observed.txt new file mode 100644 index 00000000..da4e0444 --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-5-identity-only-deleted.observed.txt @@ -0,0 +1,99 @@ +# Observed: step 5 (completeness.identityOnly === false) deleted + +$ bun test packages/catalog-envelope/test/validate-steps.test.ts + +bun test v1.3.14 (0d9b296a) + + +packages/catalog-envelope/test/validate-steps.test.ts: +(pass) five ordered validation steps > the reason-to-step mapping is 1:1 and closed [0.06ms] +(pass) five ordered validation steps > malformed-invalid-json.json is rejected at step 1 as invalid-json [0.93ms] +(pass) five ordered validation steps > malformed-missing-or-wrong-field.json is rejected at step 2 as missing-or-wrong-required-field [0.40ms] +(pass) five ordered validation steps > malformed-unrecognized.json is rejected at step 3 as unrecognized-schema-or-dialect-or-capability [0.07ms] +(pass) five ordered validation steps > malformed-missing-source-digest.json is rejected at step 4 as missing-source-digest [0.07ms] +82 | +83 | for (const testCase of CASES) { +84 | test(`${testCase.fixture} is rejected at step ${testCase.step} as ${testCase.reason}`, () => { +85 | const result = validateEnvelope(fixtureText(testCase.fixture), VALIDATE_OPTIONS); +86 | +87 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:87:30) +(fail) five ordered validation steps > malformed-identity-only.json is rejected at step 5 as identity-only-true [0.25ms] + 99 | } +100 | +101 | test('the five rejections are five distinct reasons at five distinct steps', () => { +102 | const observed = CASES.map((testCase) => { +103 | const result = validateEnvelope(fixtureText(testCase.fixture), VALIDATE_OPTIONS); +104 | if (result.outcome !== 'rejected') throw new Error(`${testCase.fixture} was not rejected`); + ^ +error: malformed-identity-only.json was not rejected + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:104:96) + at map (1:11) + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:102:28) +(fail) five ordered validation steps > the five rejections are five distinct reasons at five distinct steps [0.26ms] +(pass) five ordered validation steps > step 4 is reached only after steps 2 and 3 have passed [0.09ms] +121 | expect(result.examined.entityRecordsInspected).toBe(3); +122 | }); +123 | +124 | test('step 5 is reached only after every source digest has been verified', () => { +125 | const result = validateEnvelope(fixtureText('malformed-identity-only.json'), VALIDATE_OPTIONS); +126 | expect(result.outcome).toBe('rejected'); + ^ +error: expect(received).toBe(expected) + +Expected: "rejected" +Received: "valid" + + at (/Users/markbeacom/github/mbeacom/copilot-worktrees/adrkit/mbeacom-super-succotash/packages/catalog-envelope/test/validate-steps.test.ts:126:28) +(fail) five ordered validation steps > step 5 is reached only after every source digest has been verified [0.11ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an unrecognized schemaVersion at step 3 [0.08ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects a globDialect.version other than 4.0.5 at step 3 [0.05ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.dot true at step 3 [0.04ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.nocase true at step 3 [0.06ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects globDialect.options.nonegate false at step 3 [0.05ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an empty capabilities array at step 3 [0.03ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects an extra capability at step 3 [0.04ms] +(pass) step 3 checks the frozen matcher contract by exact value > rejects a different single capability at step 3 [0.04ms] +(pass) step 4 rejects a mismatching digest as well as a missing one > a source digest that does not match the actual bytes is rejected at step 4 [0.10ms] +(pass) step 4 rejects a mismatching digest as well as a missing one > a source whose file cannot be read is rejected at step 4, naming the path [0.21ms] +(pass) step 5 reads the boolean and nothing else > an envelope whose entities are all annotation-absent is accepted [0.11ms] +(pass) step 5 reads the boolean and nothing else > the valid fixture passes all five steps [0.08ms] +(pass) step 2 checks every nesting level > rejects repository.revision missing at step 2 [0.06ms] +(pass) step 2 checks every nesting level > rejects globDialect.options.nocase not a boolean at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects completeness.wholeCatalog not a boolean at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects entities[0].derivedPaths not a string array at step 2 [0.05ms] +(pass) step 2 checks every nesting level > rejects entities[0].sourceDocument.documentIndexInFile not an integer at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects entities[2].ownershipState not recognized at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects entities[0].provenance empty at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects entities[0].identity.allRefs empty at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an entity record carrying a sixth field at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects a flatter canonicalId/refs/paths triple at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects an unrecognized top-level field at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects sources not an array at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an envelope missing schemaVersion at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects an envelope missing repository at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing generatorVersion at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an envelope missing globDialect at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an envelope missing capabilities at step 2 [0.07ms] +(pass) step 2 checks every nesting level > rejects an envelope missing completeness at step 2 [0.04ms] +(pass) step 2 checks every nesting level > rejects an envelope missing sources at step 2 [0.03ms] +(pass) step 2 checks every nesting level > rejects an envelope missing entities at step 2 [0.02ms] +(pass) step 2 checks every nesting level > rejects an envelope missing digest at step 2 [0.02ms] + +3 tests failed: +(fail) five ordered validation steps > malformed-identity-only.json is rejected at step 5 as identity-only-true [0.25ms] +(fail) five ordered validation steps > the five rejections are five distinct reasons at five distinct steps [0.26ms] +(fail) five ordered validation steps > step 5 is reached only after every source digest has been verified [0.11ms] + + 39 pass + 3 fail + 154 expect() calls +Ran 42 tests across 1 file. [50.00ms] + +exit 1 diff --git a/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-5-identity-only-deleted.patch b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-5-identity-only-deleted.patch new file mode 100644 index 00000000..bda0c73e --- /dev/null +++ b/specs/010-catalog-backstage/evidence/negative-cases/consumer-steps/step-5-identity-only-deleted.patch @@ -0,0 +1,13 @@ +diff --git a/packages/catalog-envelope/src/validate/index.ts b/packages/catalog-envelope/src/validate/index.ts +index 0018320..366ce4e 100644 +--- a/packages/catalog-envelope/src/validate/index.ts ++++ b/packages/catalog-envelope/src/validate/index.ts +@@ -442,7 +442,7 @@ export function validateParsedEnvelope( + // ---- Step 5: completeness.identityOnly === false ---- + const at5 = (): EnvelopeExamination => examination(5, inspected, [...verified]); + +- if (completeness['identityOnly'] !== false) { ++ if (false && completeness['identityOnly'] !== false) { + return reject( + 'identity-only-true', + 'completeness.identityOnly is true, so this envelope is partial/identity-only and unusable for path-ownership matching', diff --git a/specs/010-catalog-backstage/tasks.md b/specs/010-catalog-backstage/tasks.md index 83543152..fce3ec0e 100644 --- a/specs/010-catalog-backstage/tasks.md +++ b/specs/010-catalog-backstage/tasks.md @@ -405,7 +405,7 @@ envelope it is *given*; it never generates one. Its expected values come from 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 +- [X] 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 @@ -413,7 +413,7 @@ concurrently with Phase D. Depends: T002, T007 Contract: `package-boundary.md` §5 -- [ ] T026 [P] [US8] Add a guard test proving this feature leaves +- [X] 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`. @@ -421,14 +421,14 @@ concurrently with Phase D. Discharges: FR-004 Depends: T002 -- [ ] T027 [P] [US8] Author the envelope fixtures under `/test/fixtures/` — +- [X] 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 +- [X] 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 @@ -436,7 +436,7 @@ concurrently with Phase D. Depends: T025, T027 Contract: `snapshot-envelope.md` §2 -- [ ] T029 [US8] **Observed failing, per step, individually.** Drive each of the five +- [X] 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. @@ -446,14 +446,14 @@ concurrently with Phase D. 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 +- [X] 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`, +- [X] 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 @@ -461,7 +461,7 @@ concurrently with Phase D. Depends: T027, T028 Contract: `snapshot-envelope.md` §3 -- [ ] T032 [P] [US8] Implement staleness as **exact revision inequality** at +- [X] 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. @@ -470,7 +470,7 @@ concurrently with Phase D. Depends: T027, T028 Contract: `snapshot-envelope.md` §4 -- [ ] T033 [P] [US8] Implement repository identity handling at +- [X] 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. @@ -480,14 +480,14 @@ concurrently with Phase D. Depends: T027, T028 Contract: `snapshot-envelope.md` §5, §6 -- [ ] T034 [US8] Implement `CatalogSnapshot`-shaped derivation at +- [X] 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 +- [X] 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`. @@ -495,7 +495,7 @@ concurrently with Phase D. 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 +- [X] 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`. @@ -504,7 +504,7 @@ concurrently with Phase D. Depends: T029, T030, T034 Contract: `snapshot-envelope.md` §7 -- [ ] T037 [US8] FR-044 behavioural half: assert the consumer imports nothing from +- [X] 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`.