Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 96 additions & 10 deletions packages/catalog-envelope/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions packages/catalog-envelope/src/digest/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading