diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index fa8ad2a80..4ecb0e79d 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2904,6 +2904,16 @@ re-enter active lanes. The three-arm rehearsal checks this closure against real providers; derived readiness is not evidence. General historical import and the remaining D3 qualification/explicit cutover approval are still separate work. +Runtime-shadow parity and source-partition continuity exclude only +`resume_condition.evaluated_at` from their semantic digests. That field is a +query-clock observation, so another read of unchanged durable source must not +manufacture drift or break a later writer's continuity proof. The evaluated +decision and all other resume facts remain compared; a change to readiness, +reason, generation, target, or any other Todo/lease field still fails parity or +continuity until captured. Prepared outbox bytes and supplied projections remain +fully verified, and the complete record, including the observation timestamp, +remains available to readers and in the candidate snapshot. + Quota scope/claim selection and resume planning now share one typed read boundary. It consumes existing legacy/canonical summaries without a provider-specific rule fork. User gate scope is distinct from Agent execution ownership, including in diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 2e7c50c9a..7295ab0cc 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -2298,6 +2298,14 @@ commit receipt。各 provider 的 CAS/replay 边界、legacy 源顺序兼容、 活动 lane。三臂演练用真实 provider 检查此闭合;派生 readiness 不充当证据。通用历史 导入、剩余 D3 资格化与显式 cutover 批准仍是后续工作。 +Runtime-shadow parity 与 source-partition continuity 的语义 digest 都只排除 +`resume_condition.evaluated_at`。该字段是查询时钟 observation;再次读取未变化的 +持久来源不应凭空制造 drift,也不应破坏后续 writer 的连续性证明。已求值的决策和 +其余 resume 事实仍全部参与比较:readiness、reason、generation、target 或任何其他 +Todo/lease 字段变化时,仍须先被 capture,否则 parity 或 continuity 必须失败。 +Prepared outbox 字节和传入 projection 仍做完整校验;完整记录(包括 observation 时间) +也仍保留给 reader 和候选快照。 + Quota scope/claim 选择与 resume planning 现共用一个 TS 只读边界,消费既有 legacy/canonical summary,不分叉 provider 专用规则。User gate 作用域与 Agent 执行归属分开解释,active-next-action 也遵守此区分;有意语义变化与删除的 Python diff --git a/loopx/control_plane/coordination/local_authority_shadow.ts b/loopx/control_plane/coordination/local_authority_shadow.ts index 7570f93ca..15ad4e57a 100644 --- a/loopx/control_plane/coordination/local_authority_shadow.ts +++ b/loopx/control_plane/coordination/local_authority_shadow.ts @@ -668,11 +668,58 @@ function decodeReadRequest(value: unknown): ReadRequest { }; } +/** + * Remove query-clock observations from one Todo before authority comparison. + * + * `resume_condition.evaluated_at` records when a reader evaluated an otherwise + * durable resume condition. Re-reading unchanged source therefore changes + * that timestamp without changing the Todo decision. The evaluated outcome + * and every other resume fact remain in the authority identity, so an actual + * readiness transition still produces drift until the writer captures it. + */ +function todoAuthorityIdentityView(value: unknown): unknown { + if (value === null || typeof value !== "object" || Array.isArray(value)) return value; + const todo = structuredClone(value as JsonObject); + const condition = todo.resume_condition; + if (condition !== null && typeof condition === "object" && !Array.isArray(condition)) { + const stableCondition = { ...(condition as JsonObject) }; + delete stableCondition.evaluated_at; + todo.resume_condition = stableCondition; + } + return todo; +} + +function authorityIdentityTodos(value: unknown): unknown { + return Array.isArray(value) ? value.map(todoAuthorityIdentityView) : value; +} + +function partitionAuthorityIdentityView(partition: ShadowPartition, projection: JsonObject): JsonObject { + if (partition !== "todos") return projection; + return { ...projection, todos: authorityIdentityTodos(projection.todos) }; +} + +/** + * Stable identity for one source partition. + * + * Prepared outbox bytes and the supplied projection are still compared in + * full. This semantic digest excludes only the query-clock observation that + * cannot prove a source mutation, so writer continuity and final parity use + * the same identity boundary. + */ +export function localAuthorityShadowPartitionDigest( + partition: ShadowPartition, + projection: JsonObject, +): string { + return `sha256:${createHash("sha256").update( + canonicalAuthorityBytes(partitionAuthorityIdentityView(partition, projection)), + ).digest("hex")}`; +} + /** Digest of the fields parity compares; must match Python `head_digest`. */ export function localAuthorityShadowHeadDigest(head: JsonObject): string { const view = { handoff_mode: head.handoff_mode ?? null, - todos: head.todos ?? null, + todos: authorityIdentityTodos(head.todos ?? null), leases: head.leases ?? null, }; return `sha256:${createHash("sha256").update(canonicalAuthorityBytes(view)).digest("hex")}`; @@ -952,7 +999,10 @@ function validateEntryIdentity(request: CommitEntryRequest, binding: ShadowLinea sourceReference(entry, request.partition_digest), entry.capture_lineage_id, entry.source_root_digest), "entry_identity_mismatch"); if (request.partition_projection !== null) { - requireLineage(request.partition_digest === `sha256:${canonicalAuthoritySha256(request.partition_projection)}`, + requireLineage(request.partition_digest === localAuthorityShadowPartitionDigest( + entry.partition, + request.partition_projection, + ), "partition_digest_mismatch"); } requireLineage(entry.source.kind !== "state_event_log", "event_log_writer_not_bound"); @@ -966,7 +1016,10 @@ function partitionProjection(head: JsonObject, partition: ShadowPartition): Json } function validateSourceContinuity(request: CommitEntryRequest, previous: JsonObject): void { - const digest = `sha256:${canonicalAuthoritySha256(partitionProjection(previous, request.entry.partition))}`; + const digest = localAuthorityShadowPartitionDigest( + request.entry.partition, + partitionProjection(previous, request.entry.partition), + ); requireLineage(request.entry.source.previous_partition_digest === digest, "source_partition_continuity_unproved"); if (!NO_OP_RESOLUTIONS.has(request.entry.resolution)) { requireLineage(request.partition_digest !== digest, "partition_unchanged"); diff --git a/loopx/control_plane/coordination/local_authority_shadow_projection.py b/loopx/control_plane/coordination/local_authority_shadow_projection.py index dc6401f27..295680ad4 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_projection.py +++ b/loopx/control_plane/coordination/local_authority_shadow_projection.py @@ -138,8 +138,41 @@ def lease_partition_projection( return {"leases": leases} +def _stable_todos(value: object) -> object: + """Remove only query-clock observations from Todo authority identity.""" + + if not isinstance(value, list): + return value + stable_todos: list[object] = [] + for item in value: + if not isinstance(item, Mapping): + stable_todos.append(item) + continue + todo = dict(item) + condition = todo.get("resume_condition") + if isinstance(condition, Mapping): + stable_condition = dict(condition) + stable_condition.pop("evaluated_at", None) + todo["resume_condition"] = stable_condition + stable_todos.append(todo) + return stable_todos + + +def partition_comparison_view(projection: Mapping[str, Any]) -> dict[str, Any]: + """Stable partition identity shared by capture and TS continuity checks. + + The complete prepared projection remains byte-verified separately. Only + the read-time resume evaluation clock is absent from this semantic digest. + """ + + view = dict(projection) + if "todos" in view: + view["todos"] = _stable_todos(view.get("todos")) + return view + + def partition_digest(projection: Mapping[str, Any]) -> str: - return sha256_digest(dict(projection)) + return sha256_digest(partition_comparison_view(projection)) def head_comparison_view(head: Mapping[str, Any]) -> dict[str, Any]: @@ -147,7 +180,7 @@ def head_comparison_view(head: Mapping[str, Any]) -> dict[str, Any]: return { "handoff_mode": head.get("handoff_mode"), - "todos": head.get("todos"), + "todos": _stable_todos(head.get("todos")), "leases": head.get("leases"), } @@ -170,6 +203,7 @@ def head_digest(head: Mapping[str, Any]) -> str: "head_comparison_view", "head_digest", "lease_partition_projection", + "partition_comparison_view", "partition_digest", "sha256_digest", "text_digest", diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 9557bba0b..79b50f285 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -211,6 +211,7 @@ teaches a reusable control-plane lesson. | `dashboard_open_token_picker_gap` | A bounded dashboard setting asks users to type protocol tokens such as `task_domain`; the placeholder looks like a current value, users cannot discover legal choices, or Goals without tagged work cannot enable a capability whose runtime treats the token filter as optional. | Current Goal Todo index, configured token allowlist, option-to-Todo match counts, canonical empty-filter semantics, preview payload, empty state, and packaged browser behavior. | An intentionally open optional backend vocabulary was exposed as a required product authority boundary; reading only a compact Goal-card Todo slice can also hide valid choices. | Keep the open typed token contract in the owning control-plane boundary, but present it as an optional per-Goal multi-select derived from the authoritative Todo index plus already configured values, using compact Goal Todo rows only as a compatibility fallback. Empty means no token filter while every independent admission boundary remains enforced; a non-empty selection remains a strict allowlist. Show match counts, preserve configured zero-match values, and cover unrestricted, restricted, invalid-token, preview, readback, empty-state, and packaged parity behavior. | | `app_host_heartbeat_identity_gap` | An App host can create recurring automations, but LoopX classifies it as a generic visible CLI loop; the agent may complete one phase and then stop because no host-owned successor wake is activated. Another form builds the right full scheduler packet but drops it at a compact Turn-envelope boundary that still reads a sibling App's legacy field. | Exact App versus CLI host surface, ambient thread id, runtime profile, activation packet, full and compact scheduler projections, scheduler ownership, settled-turn liveness, and terminal no-follow-up evidence. | Host capability existed, but LoopX modeled only the sibling CLI surface or left a downstream transport coupled to that sibling's packet name, so the App automation contract or successor wake did not survive the real execution path. | Add a distinct App host/runtime identity while reusing the provider-neutral `app_automation` cadence and ACK rules end to end. Bind the host's ambient thread id, preserve the packet through Turn compaction without a sibling-host alias, create/update the host automation after Todo writeback, keep settled non-terminal turns active for a fresh successor turn, and stop only on validated terminal no-follow-up. Preserve the CLI host and its native visible Goal path. Never reuse another App's local-store fallback. | | `artifact_without_goal_delta` | Repeated individually valid changes leave the requested user outcome or peer handoff unqualified; completion reports count fields, receipts or PRs. | Current user/task acceptance, latest main and related work, actual caller/readback, `problem_context` delivery judgment and remaining dependency. | Work was selected and settled around implementation artifacts rather than an independently useful outcome slice. | Reconcile the accepted goal, consolidate the missing integration/negative/readback work, or justify a prerequisite with its real successor and owner. Update the existing task/vision through its owner when direction changed. Do not repair by minimum LOC/PR quotas, a second task ledger, fabricated follow-ups or stronger prose alone. | +| `runtime_shadow_query_clock_drift` | A runtime-shadow bootstrap reports matched, then an immediate read-only inspect reports drift even though no Todo or lease writer ran; a subsequent captured write can also stop with `source_partition_continuity_unproved`. | Expected and observed parity and partition digests, field-level normalized projection diff, resume-condition decision fields, prepared outbox bytes, and source mutation receipts. | `resume_condition.evaluated_at` is recomputed from the reader clock and was included in durable authority identity, so observation time changed on every read and invalidated both parity and writer continuity. | Exclude only the query-clock observation from parity and partition semantic digests in the TS owner and its Python codec mirror. Still verify full prepared bytes/projections and compare readiness, reason, generation, target, and every other Todo/lease fact. Cover clock-only equality plus a real decision-change mismatch, then reproduce bootstrap-to-inspect and captured-write continuity through the real CLI before promotion. | ### Runtime diagnostic drift in parity fixtures diff --git a/tests/control_plane/test_local_authority_shadow_drain.py b/tests/control_plane/test_local_authority_shadow_drain.py index 0ecabf4a2..ae2b3f9df 100644 --- a/tests/control_plane/test_local_authority_shadow_drain.py +++ b/tests/control_plane/test_local_authority_shadow_drain.py @@ -24,6 +24,46 @@ GOAL_ID = "goal-e2e" +def test_shadow_parity_ignores_only_resume_evaluation_observation_clock() -> None: + head = { + "handoff_mode": "hard_lease", + "todos": [{ + "todo_id": "todo-a", + "resume_ready": False, + "resume_condition": { + "evaluated_at": "2026-09-20T00:00:00Z", + "satisfied": False, + "availability_reason": "resume_condition_pending", + }, + }], + "leases": [], + } + later_observation = json.loads(json.dumps(head)) + later_observation["todos"][0]["resume_condition"]["evaluated_at"] = ( + "2026-09-21T00:00:00Z" + ) + + assert head_digest(head) == head_digest(later_observation) + assert partition_digest({ + "handoff_mode": head["handoff_mode"], + "todos": head["todos"], + }) == partition_digest({ + "handoff_mode": later_observation["handoff_mode"], + "todos": later_observation["todos"], + }) + + changed_decision = json.loads(json.dumps(later_observation)) + changed_decision["todos"][0]["resume_condition"]["satisfied"] = True + assert head_digest(head) != head_digest(changed_decision) + assert partition_digest({ + "handoff_mode": head["handoff_mode"], + "todos": head["todos"], + }) != partition_digest({ + "handoff_mode": changed_decision["handoff_mode"], + "todos": changed_decision["todos"], + }) + + def _fixture(tmp_path: Path) -> tuple[Path, Path, Path]: real = workspace(tmp_path / "repo") return real.registry, real.state, real.runtime diff --git a/tests/control_plane_ts/local_authority_shadow.test.ts b/tests/control_plane_ts/local_authority_shadow.test.ts index a81451438..cff60b698 100644 --- a/tests/control_plane_ts/local_authority_shadow.test.ts +++ b/tests/control_plane_ts/local_authority_shadow.test.ts @@ -16,6 +16,8 @@ import type { import { FileAuthorityStore } from "../../loopx/control_plane/coordination/file_authority_store.ts"; import { LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, + localAuthorityShadowHeadDigest, + localAuthorityShadowPartitionDigest, recordLocalAuthorityShadow, } from "../../loopx/control_plane/coordination/local_authority_shadow.ts"; @@ -38,6 +40,56 @@ function request(directory: string, operationId = "local-operation-a") { }; } +test("runtime shadow parity ignores only the resume evaluation observation clock", () => { + const head = { + handoff_mode: "hard_lease", + todos: [{ + todo_id: "todo-a", + resume_ready: false, + resume_condition: { + evaluated_at: "2026-09-20T00:00:00Z", + satisfied: false, + availability_reason: "resume_condition_pending", + }, + }], + leases: [], + }; + const laterObservation = structuredClone(head); + laterObservation.todos[0]!.resume_condition.evaluated_at = "2026-09-21T00:00:00Z"; + + assert.equal( + localAuthorityShadowHeadDigest(head), + localAuthorityShadowHeadDigest(laterObservation), + ); + assert.equal( + localAuthorityShadowPartitionDigest("todos", { + handoff_mode: head.handoff_mode, + todos: head.todos, + }), + localAuthorityShadowPartitionDigest("todos", { + handoff_mode: laterObservation.handoff_mode, + todos: laterObservation.todos, + }), + ); + + const changedDecision = structuredClone(laterObservation); + changedDecision.todos[0]!.resume_condition.satisfied = true; + assert.notEqual( + localAuthorityShadowHeadDigest(head), + localAuthorityShadowHeadDigest(changedDecision), + ); + assert.notEqual( + localAuthorityShadowPartitionDigest("todos", { + handoff_mode: head.handoff_mode, + todos: head.todos, + }), + localAuthorityShadowPartitionDigest("todos", { + handoff_mode: changedDecision.handoff_mode, + todos: changedDecision.todos, + }), + ); +}); + test("one-way file shadow captures a post-commit observation without claiming parity", async (t) => { const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-shadow-")); t.after(() => rm(root, { recursive: true, force: true })); diff --git a/tests/control_plane_ts/local_authority_shadow_outbox.test.ts b/tests/control_plane_ts/local_authority_shadow_outbox.test.ts index 19176332c..495293b04 100644 --- a/tests/control_plane_ts/local_authority_shadow_outbox.test.ts +++ b/tests/control_plane_ts/local_authority_shadow_outbox.test.ts @@ -5,7 +5,11 @@ import { join } from "node:path"; import test from "node:test"; import { promisify } from "node:util"; import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; -import { commitLocalAuthorityShadowEntry, readLocalAuthorityShadow } from "../../loopx/control_plane/coordination/local_authority_shadow.ts"; +import { + commitLocalAuthorityShadowEntry, + localAuthorityShadowPartitionDigest, + readLocalAuthorityShadow, +} from "../../loopx/control_plane/coordination/local_authority_shadow.ts"; import { outboxEntryIdentity, beginLeaseOutboxEntry } from "../../loopx/control_plane/coordination/local_authority_shadow_outbox.ts"; import * as schemas from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; import { fixture, pendingEntry, settleFiles, todo, sha } from "./shadow_file_fixture.ts"; @@ -207,6 +211,34 @@ test("a missing primary mutation cannot hide behind continuous sequence numbers assert.equal((await f.store.loadAuthority() as { cursor: string }).cursor, "1"); }); +test("Todo continuity ignores only a changed resume evaluation observation clock", async (t) => { + const f = await fixture(t); + const original = todo(); + original.resume_condition = { + evaluated_at: "2026-09-20T00:00:00Z", + satisfied: false, + availability_reason: "resume_condition_pending", + }; + const first = await pendingEntry(f, 1, { handoff_mode: "hard_lease", todos: [original] }); + const delivered = await commitLocalAuthorityShadowEntry(first); + assert.equal(delivered.outcome, "delivered"); + await settleFiles(f, first, delivered); + + const reread = structuredClone(original); + (reread.resume_condition as JsonObject).evaluated_at = "2026-09-21T00:00:00Z"; + const next = structuredClone(reread); + next.text = "Durable Todo mutation after another read"; + const second = await pendingEntry( + f, + 2, + { handoff_mode: "hard_lease", todos: [next] }, + { + previousPartitionProjection: { handoff_mode: "hard_lease", todos: [reread] }, + }, + ); + assert.equal((await commitLocalAuthorityShadowEntry(second)).outcome, "delivered"); +}); + test("prose bytes may change only while the canonical previous partition remains proved", async (t) => { const f = await fixture(t); await writeFile(f.statePath, `${await readFile(f.statePath, "utf8")}\n## Notes\nProse only.\n`); @@ -223,3 +255,28 @@ test("Python and TypeScript entry identity include the same root and lineage", a assert.notEqual(outboxEntryIdentity("goal-a", "leases", 7, source, "lineage-a", root), outboxEntryIdentity("goal-a", "leases", 7, source, "lineage-b", root)); }); + +test("Python and TypeScript share the stable Todo partition digest", async () => { + const projection = { + handoff_mode: "hard_lease", + todos: [{ + ...todo(), + resume_condition: { + evaluated_at: "2026-09-21T00:00:00Z", + satisfied: false, + availability_reason: "resume_condition_pending", + }, + }], + }; + const script = [ + "import json, sys", + "from loopx.control_plane.coordination.local_authority_shadow_projection import partition_digest", + "print(partition_digest(json.loads(sys.argv[1])))", + ].join("\n"); + const result = await execFileAsync( + process.env.LOOPX_TEST_PYTHON ?? "python3", + ["-c", script, JSON.stringify(projection)], + { cwd: join(import.meta.dirname, "..", "..") }, + ); + assert.equal(result.stdout.trim(), localAuthorityShadowPartitionDigest("todos", projection)); +}); diff --git a/tests/control_plane_ts/shadow_file_fixture.ts b/tests/control_plane_ts/shadow_file_fixture.ts index 97a3be5a9..d3ca2f3a9 100644 --- a/tests/control_plane_ts/shadow_file_fixture.ts +++ b/tests/control_plane_ts/shadow_file_fixture.ts @@ -8,6 +8,7 @@ import { canonicalAuthorityBytes, canonicalAuthoritySha256 } from "../../loopx/c import { TODO_CANONICAL_READ_RECORD_FIELDS } from "../../loopx/control_plane/coordination/coordination_projection.ts"; import * as schemas from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; import { FileAuthorityStore } from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import { localAuthorityShadowPartitionDigest } from "../../loopx/control_plane/coordination/local_authority_shadow.ts"; import { outboxEntryIdentity } from "../../loopx/control_plane/coordination/local_authority_shadow_outbox.ts"; import { bootstrapCoordinationRuntimeShadow } from "../../loopx/control_plane/coordination/runtime_shadow.ts"; import { requireShadowCaptureBinding } from "../../loopx/control_plane/coordination/shadow_management.ts"; @@ -52,6 +53,7 @@ export async function fixture(t: TestContext): Promise { } export async function pendingEntry(f: ShadowFixture, seq: number, part: JsonObject, options: { partition?: "todos" | "leases"; resolution?: string; writeClass?: string; marker?: boolean; + previousPartitionProjection?: JsonObject; } = {}): Promise { const partition = options.partition ?? "todos"; const binding = await requireShadowCaptureBinding(f.root, "goal-a"); @@ -62,7 +64,10 @@ export async function pendingEntry(f: ShadowFixture, seq: number, part: JsonObje const sourceBytes = Buffer.from(`primary transaction ${partition}:${seq}\n`); if (partition === "todos" && options.resolution !== "abandoned") await writeFile(f.statePath, sourceBytes); const source = { kind: partition === "todos" ? "markdown_active_state" : "task_lease_record", - previous_partition_digest: `sha256:${canonicalAuthoritySha256(previousPartition)}`, + previous_partition_digest: localAuthorityShadowPartitionDigest( + partition, + options.previousPartitionProjection ?? previousPartition, + ), previous_bytes_digest: sha(previous), bytes_digest: sha(sourceBytes), lease: null, event_id: null }; const entryId = outboxEntryIdentity("goal-a", partition, seq, source.bytes_digest, binding.capture_lineage_id, binding.source_root_digest); const preparedAt = `2026-09-06T00:00:${String(seq).padStart(2, "0")}.000Z`; @@ -75,7 +80,7 @@ export async function pendingEntry(f: ShadowFixture, seq: number, part: JsonObje const prepared = { schema_version: schemas.LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA, goal_id: "goal-a", capture_lineage_id: binding.capture_lineage_id, entry_id: entryId, partition, seq, writer, source, source_root_digest: binding.source_root_digest, projection: recordedProjection, - partition_digest: partition === "todos" ? `sha256:${canonicalAuthoritySha256(part)}` : null, prepared_at: preparedAt }; + partition_digest: partition === "todos" ? localAuthorityShadowPartitionDigest(partition, part) : null, prepared_at: preparedAt }; const preparedBytes = `${JSON.stringify(prepared, null, 2)}\n`; await writeFile(join(directory, `${stem}.prepared.json`), preparedBytes); const marker = options.marker ?? true; @@ -88,7 +93,8 @@ export async function pendingEntry(f: ShadowFixture, seq: number, part: JsonObje entry: { capture_lineage_id: binding.capture_lineage_id, entry_id: entryId, partition, seq, writer, source, source_root_digest: binding.source_root_digest, prepared_at: preparedAt, committed_at: marker ? committedAt : null, prepared_sha256: sha(preparedBytes), committed_sha256: marker ? sha(markerBytes) : null, resolution }, - partition_projection: noOp ? null : part, partition_digest: noOp ? null : `sha256:${canonicalAuthoritySha256(part)}` }; + partition_projection: noOp ? null : part, + partition_digest: noOp ? null : localAuthorityShadowPartitionDigest(partition, part) }; } export async function settleFiles(f: ShadowFixture, request: JsonObject, result: JsonObject, previousDigest: string | null = null): Promise { const entry = request.entry as JsonObject;