From 68f73c1ea97c63b72fa5dab6e54ee75f61a58e81 Mon Sep 17 00:00:00 2001 From: ancienttwo Date: Sat, 15 Aug 2026 23:52:39 +0800 Subject: [PATCH] fix(projection): stamp entity docs against a canonical body digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node-scoped key introduced for 0.4.3 ({scale signal, semantic proof}) was not a complete "body would be byte-identical" predicate: renderEntitySummary also prints node status, summary, localContracts, entrypoints, and relations, none of which enter the semantic compilation. A model-level status flip rewrote the body while the stamp was reused, and once the new body landed the marker outputDigest matched, so drift went clean — a permanently silent false stamp. Hash the entity body rendered with a fixed placeholder stamp (CANONICAL_STICKY_STAMP) instead. The digest is a total function of every body input except the stamp itself, so future body inputs are covered by construction and restamping stays truthful. Sticky decision logic, restamp-unmeasured semantics, non-entity targets, renderer version (v3), and package versions (0.4.3) are unchanged. --- packages/core/projection-engine/src/index.ts | 87 +++++++++++++------ .../test/entity-summary.test.ts | 85 ++++++++++++++++++ 2 files changed, 146 insertions(+), 26 deletions(-) diff --git a/packages/core/projection-engine/src/index.ts b/packages/core/projection-engine/src/index.ts index 8912be8..06d820e 100644 --- a/packages/core/projection-engine/src/index.ts +++ b/packages/core/projection-engine/src/index.ts @@ -342,21 +342,30 @@ export function renderArchitectureDocumentationProjection(input: { resulting: semanticState, acceptedChange: input.refreshContext?.acceptedChange }); - // Each entity-summary target keys its marker on the inputs of its *own* node: the measured scale - // signal and the semantic proof digest (which already folds the model-wide relations that touch - // this node's diagrams). Those are the only render inputs of the entity body besides the stamp, - // so a node-scoped digest is exactly the "body would be byte-identical" predicate the sticky - // stamp needs. A re-measured footprint is then reported as `projection-generated-region-stale` - // on the one document that carries it instead of every entity target, and a commit that touches - // nothing under a node's footprint no longer invalidates it. + // Each entity-summary target keys its marker on a *canonical render of its own body*: the body + // rendered once with the fixed placeholder stamp `CANONICAL_STICKY_STAMP`, then digested. The + // placeholder stands in for the one body input the key must not depend on — the stamp itself — + // so the digest is a total function of every other render input of the entity body: status, + // summary, `extensions.localContracts`, `source.include`/`source.entrypoints`, both relation + // lists, the measured scale signal, and the semantic P1/P2 compilation. Because the key is the + // rendered string rather than an enumeration of inputs, a future body input is covered + // automatically instead of having to be remembered. The key is therefore exactly the + // "body would be byte-identical" predicate the sticky stamp needs: a status flip that moves the + // body moves the digest, so a stamp can never be silently reused against a commit that never + // rendered the body it now names, and a re-measured footprint is still reported as + // `projection-generated-region-stale` on the one document that carries it instead of every + // entity target. // - // The plan-wide `input.sourceDigest` (the moving tree) and `verifiedAgainst` are deliberately - // *not* inputs here. Folding the tree digest in would couple every entity document to unrelated - // commits; and `verifiedAgainst` is a sticky stamp (see `stickyVerifiedAgainst` below), not a - // render input: folding a moving HEAD into the digest would leave the projection without a fixed - // point — every commit would invalidate the document that the previous commit had just - // re-projected. Index/diagram/decision/relation targets render no per-node measurement, so they - // keep recording `input.sourceDigest` and stay byte-identical to their pre-change output. + // The plan-wide `input.sourceDigest` (the moving tree) and the real `verifiedAgainst` are + // deliberately *not* folded in beyond the placeholder. Folding the tree digest in would couple + // every entity document to unrelated commits; and `verifiedAgainst` is a sticky stamp (see + // `stickyVerifiedAgainst` below), not a render input: folding a moving HEAD into the digest + // would leave the projection without a fixed point — every commit would invalidate the document + // that the previous commit had just re-projected. `generatedAt` does not enter entity bodies + // (only architecture-index prints it), so it cannot perturb the canonical digest. + // Index/diagram/decision/relation/changelog targets render no per-node measurement, so they keep + // recording the plan-wide `input.sourceDigest` and stay byte-identical to their pre-change + // output. const targetDrafts = architectureDocumentationTargetDrafts(model, layout); const declaresSourceByNodeId = new Map(model.nodes.map((node) => [node.id, (nativeNodeSource(node)?.include ?? []).length > 0])); const changeSinceStampByNodeId = new Map(input.sourceChangesSinceStamp.map((entry) => [entry.nodeId, entry])); @@ -365,15 +374,25 @@ export function renderArchitectureDocumentationProjection(input: { const existing = existingByPath.get(draft.path); const nodeId = draft.type === "entity-summary" ? draft.scope.id : undefined; // Targets that render no per-node measurement key on the plan-wide digest; an entity-summary - // target keys on its own node's inputs (see the comment above the render loop). + // target keys on the canonical digest of its own rendered body (see the comment above the + // render loop). let targetSourceDigest = input.sourceDigest; let targetVerifiedAgainst: ArchitectureProjectionVerifiedAgainst | undefined; if (nodeId !== undefined) { - const compilation = semanticCompilationsByNodeId.get(nodeId)!; - targetSourceDigest = digestJson({ - sourceScaleSignal: scaleSignalsByNodeId.get(nodeId) ?? null, - semanticProof: { capabilityId: compilation.capabilityId, proofDigest: compilation.proofDigest } - } as unknown as Json); + // Canonical render: the same body this run would emit, stamped with the placeholder instead + // of the decided stamp. Rendering the body (rather than enumerating its inputs) also keeps + // the fail-closed error paths in front of the stamp decision — a node that declares + // `source.include` with no measured scale signal throws + // `architecture-docs-projection-scale-signal-missing` here. + targetSourceDigest = digestJson(renderTargetGeneratedBody(draft, model, { + generatedAt, + verifiedAgainst: CANONICAL_STICKY_STAMP, + scaleSignalsByNodeId, + semanticCompilationsByNodeId, + decisions: input.decisions ?? [], + timeline: input.timeline ?? [], + layout + })); const decision = stickyVerifiedAgainst(existing, draft.targetId, targetSourceDigest, { nodeDeclaresSource: declaresSourceByNodeId.get(nodeId) === true, measurement: changeSinceStampByNodeId.get(nodeId) @@ -1834,10 +1853,11 @@ function architectureDocumentationProjectionDrift(input: { continue; } if (metadata.outputDigest !== expected.generatedBodyDigest) { - // Internally consistent, but not what this run renders. Entity targets record a node-scoped - // key (scale signal + semantic proof), so a body input outside that key — a node name or - // status flip, a relation re-wording — lands here: a document awaiting re-verification, a - // stale projection, not a hand edit. + // Internally consistent, but not what this run renders. Entity targets key their marker on + // the canonical body digest, so a moved body input (status flip, summary edit, re-measured + // footprint) is already caught by the sourceDigest check above; what survives to here is a + // region whose inputs match but whose stamp decision moved the body. Either way this is a + // document awaiting re-verification, a stale projection, not a hand edit. diffs.push({ path: expected.path, targetId: expected.target.targetId, @@ -1936,6 +1956,20 @@ function parseMarkerVerifiedAgainst(value: string | undefined): ArchitectureProj } } +/** + * Fixed placeholder stamp for the canonical render each entity-summary target keys its sticky + * marker digest on (`targetSourceDigest`): the digest must be a function of every body input + * *except* the stamp, so the canonical render pins the stamp to this constant. The value itself is + * never validated, written to a marker, or compared against a real ref — only the digest of the + * body rendered under it is used. Any constant would do; this one is fixed forever so the key stays + * stable across renderer runs. + */ +const CANONICAL_STICKY_STAMP: ArchitectureProjectionVerifiedAgainst = { + branch: "__sticky-canonical__", + commit: "0000000", + committedAt: "1970-01-01T00:00:00.000Z" +}; + type StickyVerifiedAgainstDecision = | { kind: "reuse"; verifiedAgainst: ArchitectureProjectionVerifiedAgainst } | { kind: "restamp" } @@ -1949,8 +1983,9 @@ type StickyVerifiedAgainstDecision = * * A stamp sticks when **both** hold: * - * 1. The region was rendered from the inputs this run computed (`nodeSourceDigest` — the target's - * own node's scale signal and proof digest — matches, so the body would be byte-identical), and + * 1. The region was rendered from the body this run computes (`nodeSourceDigest` — the digest of + * the canonical placeholder-stamped render of the target's own body — matches, so the body + * would be byte-identical but for the stamp line and marker attributes), and * 2. no file inside the node's declared `source.include` footprint changed after the stamped * commit — as measured by the caller. * diff --git a/packages/core/projection-engine/test/entity-summary.test.ts b/packages/core/projection-engine/test/entity-summary.test.ts index 96a3892..3d78a93 100644 --- a/packages/core/projection-engine/test/entity-summary.test.ts +++ b/packages/core/projection-engine/test/entity-summary.test.ts @@ -708,3 +708,88 @@ describe("node-scoped sticky key", () => { } }); }); + +describe("canonical body digest sticky key", () => { + const nodeA = "capability.docs.projection"; + const nextRef = { branch: "main", commit: "0badc0de", committedAt: "2026-08-09T09:30:00+08:00" }; + + /** The default model with only model-level fields flipped: status, summary, local contracts. */ + const statusFlippedModel: NativeModel = { + nodes: model.nodes.map((node) => node.id === nodeA ? { + ...node, + status: "deprecated", + summary: "Projects capability facts into module documentation. Superseded by the ledger.", + extensions: { + localContracts: ["packages/core/projection-engine/CLAUDE.md", "packages/core/projection-engine/AGENTS.md"] + } + } : node), + relations: model.relations, + flows: model.flows + }; + + test("a model-level status flip re-stamps even when every footprint measurement is unchanged", () => { + const run1 = render(); + const existingFiles = [...run1.files.map(({ path, body }) => ({ path, body })), run1.manifest]; + + // Only model-level fields outside any source footprint move: the node's status flips, its + // summary is reworded, and a contract file is added. The global tree digest is unchanged and + // the per-node measurement still reports nothing changed under the footprint — exactly the + // edit a key over scale signal + proof digest alone would let slip through with a stale stamp. + const run2 = render({ model: statusFlippedModel, verifiedAgainst: nextRef, existingFiles }); + + // The rendered body moved, so the node re-stamps with the ref that rendered the new body; + // reusing run 1's stamp would attribute the new prose to a commit that never rendered it. + expect(entityFile(run2, nodeA).verifiedAgainst).toEqual(nextRef); + expect(entityFile(run2, nodeA).body).toContain("> **狀態**:`deprecated`"); + expect(entityFile(run2, nodeA).body).toContain("packages/core/projection-engine/AGENTS.md"); + expect(entityFile(run2, nodeA).body).toContain("> **Verified against**:`main@0badc0de`(2026-08-09)"); + expect(entityFile(run2, nodeA).body).not.toContain("`main@7415329`"); + }); + + test("once a status-flip re-render lands, the honest stamp holds as the new fixed point", () => { + const run3Ref = { branch: "main", commit: "feedface", committedAt: "2026-08-10T09:30:00+08:00" }; + const reapply = (base: ArchitectureDocumentationProjectionPlan, verifiedAgainst: typeof nextRef) => render({ + model: statusFlippedModel, + verifiedAgainst, + // The measurement is keyed to the commit the on-disk document is actually stamped with. + sourceChangesSinceStamp: [{ nodeId: nodeA, commit: nextRef.commit, status: "unchanged" }], + existingFiles: [...base.files.map(({ path, body }) => ({ path, body })), base.manifest] + }); + + const run1 = render(); + const run2 = render({ + model: statusFlippedModel, + verifiedAgainst: nextRef, + existingFiles: [...run1.files.map(({ path, body }) => ({ path, body })), run1.manifest] + }); + expect(entityFile(run2, nodeA).verifiedAgainst).toEqual(nextRef); + + // Run 2's output is what sits on disk after `docs apply`; HEAD has moved on again. This is + // the state a false stamp used to hide in: once the marker's outputDigest matched the new + // body, the stale stamp read clean. Now every document holds with run 2's stamp — never + // run 1's. The manifest is the one file still stale: its receipt digests the major-change + // classification, which settles only after the semantic delta has been consumed by the first + // post-apply render — a property of the baseline machinery, identical with or without this + // fix, and orthogonal to stamps. + const run3 = reapply(run2, run3Ref); + expect(run3.drift.diffs).toEqual([expect.objectContaining({ + path: "docs/architecture/.projection-manifest.json", + reasonCode: "projection-manifest-stale" + })]); + expect(run3.notices).toEqual([]); + for (const file of run2.files) { + expect(run3.files.find((entry) => entry.path === file.path)?.body).toBe(file.body); + } + expect(entityFile(run3, nodeA).verifiedAgainst).toEqual(nextRef); + expect(entityFile(run3, nodeA).body).toContain("`main@0badc0de`"); + expect(entityFile(run3, nodeA).body).not.toContain("`main@7415329`"); + + // And the settle completes on the next apply: re-projecting run 3's output with identical + // inputs is fully clean — documents and manifest — still carrying run 2's stamp. + const run4 = reapply(run3, run3Ref); + expect(run4.drift.ok).toBe(true); + expect(run4.drift.diffs).toEqual([]); + expect(run4.files.map((file) => file.body)).toEqual(run3.files.map((file) => file.body)); + expect(entityFile(run4, nodeA).verifiedAgainst).toEqual(nextRef); + }); +});