diff --git a/docs/architecture.md b/docs/architecture.md index 13a84a9d..f98ccc65 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ This section describes the current product tree. Workspace experiments outside t - The SQLite briefing path verifies the complete first-version material snapshot under one blob access lease, pins `source-groups-v1` and the packaged prompt contract, and refuses silent capacity truncation before publishing a fixed-width placeholder template. The write transaction takes a fresh acquisition time, grants the full 30-minute lease, and stores a small canonical envelope that binds RequestId, input checksum, subject, template pointer, and exact final lease. Replay verifies both authorities and overlays only the fixed-width lease fields, preserving the capacity fixed point while reconstructing the exact original `HostDistillBriefing` without consulting the current job or prompt. Duplicate ingest preserves a same-generation lease, while a changed generation replaces the pending job and cascades its prior lease. - SQLite commit rebuilds evidence context from verified pending, lease, current-version and material rows, validates referenced blob bytes, applies the claim-only patch with pinned algorithms, and derives claims, quality, mechanical review reasons, `VersionId`, Profile and prompt deterministically. Its one write transaction rechecks RequestId, job, generation, lease, pointers, material membership and subject identity; writes the complete immutable version authority; moves current or suspended status/pointers; removes pending/lease; and appends the stable result plus two events. A later ingest can therefore brief against a verified current baseline. The accepted-patch digest is part of a sealed private authority record; current/suspended uniqueness and same-subject lineage are constrained by schema and verified on direct reads. - The SQLite review query reads active subject pointers and only the directly needed immutable current/candidate rows inside one consistent snapshot, reconstructs their canonical profiles, and returns stable current-versus-candidate diffs without consulting pending work or full history. Promote and reject use active-candidate CAS in one write transaction; promote makes the previous current historical and rebases remaining material work, while reject preserves the pending row and lease byte-for-byte. Rollback accepts one same-subject historical target and creates a new immutable current descendant that copies its semantic snapshot while recording the new parent, actor, time, source, and `VersionId`. Promote and rollback replace any remaining pending work with a fresh JobId, mutation-time queue timestamp, recomputed delta, and no lease. All three store exact operation results plus reason/lineage-bearing events in the same transaction and use SQLite/WAL rather than mutation journals for crash recovery. -- SQLite correction binds direct-user or relayed provenance to the trusted actor, stores its normalized private body through the shared blob store, and creates one full-body `user_asserted` replacement claim. One transaction writes material membership, immutable current or suspended version, optional candidate replacement lineage, pointers/statuses, fresh no-lease pending work, the stable operation result, and fixed events. A current correction intentionally permits a zero-delta pending job. Real pre/post-COMMIT process kills prove that reopen observes only the old world or the complete correction; there is no correction journal, staging, or recovery path. +- SQLite correction binds direct-user or relayed provenance to the trusted actor, stores its normalized private body through the shared blob store, and creates one full-body `user_asserted` replacement claim. One transaction writes material membership, immutable current or suspended version, optional candidate replacement lineage, pointers/statuses, fresh no-lease pending work, the stable operation result, and fixed events. Correction versions include only their selected content baseline's materials and the correction itself; ingested research outside that baseline remains pending, including after candidate promotion. A current correction permits a zero-delta pending job only when no unprocessed materials remain. Real pre/post-COMMIT process kills prove that reopen observes only the old world or the complete correction; there is no correction journal, staging, or recovery path. - The package-private SQLite read service supplies subject list/resolution, Profile get/prompt/status, material list/get, version list/diff/lineage, and direct Library list from one root authority. Ordinary reads verify the subject rows, immutable versions, events, and content blobs used by the requested result; current material rows are rebound to the subject material-set hash and pending total before material or Library delivery. Material reads acquire shared blob access before taking their SQLite snapshot and retain it through digest-verified delivery, including the raw blob behind `raw_extract` before reporting `rawAvailable=true`. The Library is a direct SQLite aggregate for this Preview, not a second stored authority or a generic projection framework. - An explicit `legacy-file-engine.test.fixture.ts` keeps the superseded package-internal material/profile/version/Library read pipeline executable without entering the SQLite composition. Supporting test-only files retain the former file-backed commit, immutable-version staging, lease/commit recovery, and a copied legacy review query solely as regression setup for those reads. The former file review/rollback mutation service and its recovery branches are removed; none of these fixtures is a compatibility fallback or production-package entry. - The retained package-internal file read slice and checksummed JSON Library projection remain executable only as uncomposed regression surfaces for the earlier fact tree. They continue to prove their historical cursor, snapshot, and corruption behavior while the Developer Preview uses neither path. diff --git a/docs/design/system-v3.md b/docs/design/system-v3.md index d7d860e2..c0c3dfe0 100644 --- a/docs/design/system-v3.md +++ b/docs/design/system-v3.md @@ -4339,7 +4339,7 @@ Correction 是完整产品 mutation,不是 ingest + commit 两次提交。它 2. 根据可信 actor 生成 direct_user 或 relayed provenance;正文通过通用 BlobStore put,MaterialId 仍绑定 request-stable correction source identity 与 provenance。 3. 从 current 或 exact active candidate 读取内容基线,应用一条 full-body user_asserted replacement claim;explicit targets 全部 supersededBy 同一 replacement。missing/already-superseded/duplicate/cycle invalid_input。 4. 以 transaction-time current 做 QualityGate before;candidate 只作为内容 baseline。supersedes 产生 correction_conflict,非 user actor 产生 relayed_correction,其余 mechanical reasons 与普通 commit 共用。 -5. 派生 generation+1、完整 material membership、fresh no-lease pending、VersionId/Profile/prompt、current 或 suspended disposition;替代 candidate 时记录 derivedFromCandidateVersionId 并把旧 candidate 标 rejected。 +5. 派生 generation+1、完整 subject material membership、fresh no-lease pending、VersionId/Profile/prompt、current 或 suspended disposition。correction version 的 membership 只包含选定内容基线(current 或 exact active candidate)的 membership 与本次 correction material;其 materialSetHash、materialCount 和 quality 均由该版本集合派生。仅已 ingest、尚未进入内容基线的 research 继续留在 subject 集合中,pending 相对新的 current version membership 计算差集,不得因 correction 或后续 promote 被视为已蒸馏;只有无未处理材料时才允许 zero-delta pending。替代 candidate 时记录 derivedFromCandidateVersionId 并把旧 candidate 标 rejected。 6. 在一个 SQLite transaction 内重新校验 RequestId、subject revision、current/candidate 与 targets,然后提交 correction material reference、claim/evidence/version/membership、pointer/status、pending、stable result 与 events。 transaction commit 是唯一产品提交点;projection 在 LSN 后追赶。没有 CorrectionTransactionRecord、correction/version staging、`.deleting`、target-first recovery 或 correction-specific cleanup。precondition/validation failure 使 transaction 不发生或 rollback;已 put 但未引用的 blob 由通用 GC 处理。 diff --git a/docs/design/v3/20-corrections-and-evolution.md b/docs/design/v3/20-corrections-and-evolution.md index 7fcaead2..8be5b01b 100644 --- a/docs/design/v3/20-corrections-and-evolution.md +++ b/docs/design/v3/20-corrections-and-evolution.md @@ -20,7 +20,7 @@ Correction 是完整产品 mutation,不是 ingest + commit 两次提交。它 2. 根据可信 actor 生成 direct_user 或 relayed provenance;正文通过通用 BlobStore put,MaterialId 仍绑定 request-stable correction source identity 与 provenance。 3. 从 current 或 exact active candidate 读取内容基线,应用一条 full-body user_asserted replacement claim;explicit targets 全部 supersededBy 同一 replacement。missing/already-superseded/duplicate/cycle invalid_input。 4. 以 transaction-time current 做 QualityGate before;candidate 只作为内容 baseline。supersedes 产生 correction_conflict,非 user actor 产生 relayed_correction,其余 mechanical reasons 与普通 commit 共用。 -5. 派生 generation+1、完整 material membership、fresh no-lease pending、VersionId/Profile/prompt、current 或 suspended disposition;替代 candidate 时记录 derivedFromCandidateVersionId 并把旧 candidate 标 rejected。 +5. 派生 generation+1、完整 subject material membership、fresh no-lease pending、VersionId/Profile/prompt、current 或 suspended disposition。correction version 的 membership 只包含选定内容基线(current 或 exact active candidate)的 membership 与本次 correction material;其 materialSetHash、materialCount 和 quality 均由该版本集合派生。仅已 ingest、尚未进入内容基线的 research 继续留在 subject 集合中,pending 相对新的 current version membership 计算差集,不得因 correction 或后续 promote 被视为已蒸馏;只有无未处理材料时才允许 zero-delta pending。替代 candidate 时记录 derivedFromCandidateVersionId 并把旧 candidate 标 rejected。 6. 在一个 SQLite transaction 内重新校验 RequestId、subject revision、current/candidate 与 targets,然后提交 correction material reference、claim/evidence/version/membership、pointer/status、pending、stable result 与 events。 transaction commit 是唯一产品提交点;projection 在 LSN 后追赶。没有 CorrectionTransactionRecord、correction/version staging、`.deleting`、target-first recovery 或 correction-specific cleanup。precondition/validation failure 使 transaction 不发生或 rollback;已 put 但未引用的 blob 由通用 GC 处理。 diff --git a/packages/engine/src/correction/service.sqlite.test.ts b/packages/engine/src/correction/service.sqlite.test.ts index c5fc148b..e5e46b6e 100644 --- a/packages/engine/src/correction/service.sqlite.test.ts +++ b/packages/engine/src/correction/service.sqlite.test.ts @@ -356,6 +356,131 @@ describe("SQLite CorrectionService", () => { }); }); + it.each([ + [false, "direct"], + [true, "direct"], + [false, "promote"], + [true, "promote"], + [true, "replace-candidate"], + ] as const)( + "preserves unprocessed material through correction (current=%s, route=%s), replay and reopen", + async (hasCurrent, route) => { + const root = await temporaryRoot(); + const ids = new SequenceIds(); + const clock = new FakeClock(); + const composition = await open(root, { ids, clock }); + const subject = hasCurrent + ? (await seedCurrent(composition)).ingested.subject + : await composition.subjects.create({ displayName: "Mira Chen" }, SDK_ACTOR, { + requestId: request(1), + }); + const pendingText = "Mira records tradeoffs before choosing a design."; + const ingested = await composition.ingest.ingest( + { + subject: { kind: "existing", subjectId: subject.id }, + materials: [ + { + ...firstInput().materials[0]!, + clientRef: "unprocessed", + content: pendingText, + source: { + ...firstInput().materials[0]!.source, + uri: "https://example.test/mira/unprocessed", + }, + }, + ], + enqueue: "now", + }, + SDK_ACTOR, + { requestId: request(10) }, + ); + const obsoleteBrief = await composition.leases.brief({ jobId: ingested.job!.id }, session(), { + requestId: request(11), + }); + const input = { + subjectId: subject.id, + correction: { text: "Mira marks assumptions explicitly.", facet: ASSUMPTION_A }, + }; + const actor = route === "direct" ? USER_ACTOR : HOST_ACTOR; + const corrected = await composition.corrections.correct(input, actor, { + requestId: request(12), + }); + if (route !== "direct") { + if (corrected.kind !== "suspended") throw new Error("Expected a relayed candidate."); + if (route === "promote") { + await composition.review.promote( + { subjectId: subject.id, candidateVersionId: corrected.candidate.id }, + USER_ACTOR, + { requestId: request(13) }, + ); + } else { + await composition.corrections.correct( + { + subjectId: subject.id, + correction: { + text: "Mira asks reviewers to challenge assumptions.", + facet: ASSUMPTION_B, + baseCandidateVersionId: corrected.candidate.id, + }, + }, + USER_ACTOR, + { requestId: request(13) }, + ); + } + } + expect(row(root, "SELECT added_material_count FROM pending_jobs")).toEqual({ + added_material_count: 1, + }); + expect(count(root, "job_leases")).toBe(0); + await expectCode( + composition.commits.commit(commitInput(obsoleteBrief), session(), { + requestId: request(14), + }), + "stale_job", + ); + composition.close(); + const reopened = await open(root, { ids, clock }); + const pendingBeforeReplay = row(root, "SELECT * FROM pending_jobs"); + await expect( + reopened.corrections.correct(input, actor, { requestId: request(12) }), + ).resolves.toEqual(corrected); + expect(row(root, "SELECT * FROM pending_jobs")).toEqual(pendingBeforeReplay); + const briefing = await reopened.leases.brief( + { jobId: pendingBeforeReplay!.job_id as JobId }, + session(2), + { requestId: request(15) }, + ); + expect(briefing.materials).toHaveLength(1); + expect(briefing.materials[0]).toMatchObject({ content: pendingText, ref: FIRST_REF }); + const committed = await reopened.commits.commit( + { + ...commitInput(briefing), + patch: { + operations: [ + { + op: "add", + claim: { + facet: DECISION_STYLE, + text: pendingText, + evidence: [ + { kind: "brief_material", materialRef: FIRST_REF, quote: pendingText }, + ], + }, + }, + ], + }, + }, + session(2), + { requestId: request(16) }, + ); + if (committed.kind !== "current") throw new Error("Expected a resumed current profile."); + expect(committed.profile.claims.map(({ text }) => text)).toEqual( + expect.arrayContaining([pendingText, input.correction.text]), + ); + expect(count(root, "pending_jobs")).toBe(0); + }, + ); + it("keeps ordinary duplicate ingest from fabricating a zero-delta pending job", async () => { const root = await temporaryRoot(); const composition = await open(root); diff --git a/packages/engine/src/correction/service.ts b/packages/engine/src/correction/service.ts index 3720a455..b53ed641 100644 --- a/packages/engine/src/correction/service.ts +++ b/packages/engine/src/correction/service.ts @@ -665,18 +665,31 @@ export class CorrectionService { const target = addCorrectionMaterial(currentMaterials, prepared, published); if (target.inserted) insertCorrectionMaterialInTransaction(database, prepared); - const manifestItems = materialManifestFromSqlite(target.materials); - const materialSetHash = hashMaterialSet(manifestItems); + const currentManifestItems = materialManifestFromSqlite(target.materials); + const materialSetHash = hashMaterialSet(currentManifestItems); + // A correction consumes its content baseline and its own material, not research that + // has only been ingested. Version membership also defines the next briefing's delta. + const versionMaterialIds = new Set( + contentBaseline?.manifest.items.map((entry) => entry.materialId), + ); + versionMaterialIds.add(prepared.record.id); + const versionMaterials = target.materials.filter(({ record }) => + versionMaterialIds.has(record.id), + ); + if (versionMaterials.length !== versionMaterialIds.size) { + throw storageCorrupt("SQLite correction baseline is not contained in current materials."); + } + const manifestItems = materialManifestFromSqlite(versionMaterials); const generation = authority.generation + 1; if (!Number.isSafeInteger(generation)) { throw storageCorrupt("SQLite correction generation exceeds the safe integer range."); } const grouping = deriveSourceGroups( - target.materials.map(({ record }) => record), + versionMaterials.map(({ record }) => record), "source-groups-v1", ); const evidenceIndex = buildMaterialEvidenceIndex( - target.materials.map(({ record }) => record), + versionMaterials.map(({ record }) => record), grouping, ); const replacement: ResolvedCorrectionReplacement = { @@ -730,7 +743,7 @@ export class CorrectionService { subjectId, subjectDisplayName: subjectRow.display_name, generation, - materialSetHash, + materialSetHash: hashMaterialSet(manifestItems), ...(authority.current === undefined ? {} : { parentId: authority.current.version.id }), ...(authority.suspended === undefined ? {} @@ -813,8 +826,8 @@ export class CorrectionService { generation, ...(nextCurrentId === undefined ? {} : { baseVersionId: nextCurrentId }), materialSetHash, - addedMaterialCount: manifestItems.length - baselineCount, - totalMaterialCount: manifestItems.length, + addedMaterialCount: currentManifestItems.length - baselineCount, + totalMaterialCount: currentManifestItems.length, state: "pending", queuedAt: now, };