diff --git a/docs/adr/0007-portable-candidate-knowledge-store.md b/docs/adr/0007-portable-candidate-knowledge-store.md index e99a655..09e9483 100644 --- a/docs/adr/0007-portable-candidate-knowledge-store.md +++ b/docs/adr/0007-portable-candidate-knowledge-store.md @@ -50,8 +50,8 @@ add-members operation can append unmatched accepted files from an existing binding as new managed file sources and immutable members in deterministic path order; existing member states remain report-only and a later candidate failure returns a path-free partial result. Applied refresh is limited to existing -active same-member changed files; directory removals, root rebind, rename, and -member-retirement policy remain deferred. The membership is a stable historical +active same-member changed files; complete removal reconciliation, root rebind, +rename, and broader member-retirement policy remain deferred. The membership is a stable historical mapping captured at binding or explicit append time: later source version appends, explicit origin rebinding, or source retirement do not rewrite its rows, and actual incremental scan reconciliation remains deferred. @@ -192,7 +192,15 @@ source. Each source, version, canonical origin binding, managed blob, journal commit, and immutable directory member is committed atomically per candidate; the operation stops on the first later failure and returns only path-free partial IDs. It does not create observations for new members or infer renames, -removals, rebinding, or retirement. +removals, rebinding, or retirement. An explicit approved directory-member +retirement operation performs one fresh bounded scan and accepts only an active +same-member `missing` member. It atomically records the existing +`user-requested` retirement marker with latest-version, origin-revision, and +chronology guards. Its path-free `removed` result means logical retirement, not +physical deletion; an already retired member returns `already-removed` without +a write. Bytes, versions, origin bindings, observations, journal state, and +immutable membership remain, while complete reconciliation, cleanup, and broader +lifecycle policy remain deferred. SQLite migration v13 stores the opaque directory binding and immutable hashed members in separate local-only tables with same-CKB foreign-key scope; there is no backfill of earlier runtime-only imports. @@ -392,9 +400,11 @@ configuration. Its original filename is not used in the managed layout. This decision deliberately leaves the following work unintegrated: -- directory removals, directory rebind, rename/removal, and member retirement - policy (the explicit add-members operation handles only unmatched additions, - while applied refresh handles only existing active same-member changed files); +- complete directory removal reconciliation, directory rebind, rename/removal, + and broader member-retirement policy (the explicit operation handles only one + approved missing same-member member; add-members handles only unmatched + additions, while applied refresh handles only existing active same-member + changed files); - redirect-observation history, conditional URL requests, and URL-specific failure or time-based readiness policy; - background refresh, time-based freshness policy, moved-origin discovery, and diff --git a/docs/architecture.md b/docs/architecture.md index 0eb8a00..45792ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -215,7 +215,8 @@ fallback. See [ADR 0004](adr/0004-desktop-credential-boundary.md). mapping captured at binding or explicit append time: later source version appends, explicit origin rebinding, or source retirement do not rewrite it. Incremental directory scan reconciliation, directory - rebind, rename, removal, and member-retirement policy remain unimplemented. + rebind, rename, complete removal reconciliation, and broader member-retirement + policy remain unimplemented. A local explicit bounded refresh preview can classify historical members as `current`, `changed`, `missing`, `retired`, or `origin-conflict` and count unmatched accepted files. It exposes no paths or integrity metadata and makes @@ -242,8 +243,16 @@ fallback. See [ADR 0004](adr/0004-desktop-credential-boundary.md). each file source, initial version, sensitive origin binding, managed blob, journal event, and immutable membership atomically per candidate. Existing member states are report-only, new members receive no refresh observation, and - removals, renames, root rebind, automatic retirement/deletion, and writer + complete removal reconciliation, renames, root rebind, automatic retirement/deletion, and writer coordination remain deferred. +- An explicit approved directory-member retirement operation reuses one complete + bounded scan and accepts only an active same-member `missing` member. It + atomically records the existing `user-requested` retirement marker with + latest-version, origin-revision, and chronology guards. `removed` means + logical retirement only: bytes, versions, origin bindings, observations, + journal state, and immutable membership remain; an already retired member + returns `already-removed` without a write. Complete directory reconciliation, + physical cleanup, and broader lifecycle policy remain deferred. - Managed source add and append publish verified bytes without replacement before committing their version-6 database marker. Committed markers always require matching opaque bytes; file sources retain their additional regular-file and @@ -438,7 +447,9 @@ last-observation state tied to the exact source version. An explicit rebind changes only sensitive local origin configuration after an exact latest-managed-version match and exposes no path or integrity metadata. New-member persistence is available only through the explicit bounded add-members -operation; rename/removal decisions, directory-root rebind, +operation, and explicit missing-member retirement is available only through the +approved directory-member operation; complete removal reconciliation, +directory-root rebind, automatic retirement/deletion, background refresh, time-based freshness policy, automatic moved-origin discovery, adapter-level refresh/rebind/duplicate controls, incremental diff --git a/docs/privacy-and-evaluation.md b/docs/privacy-and-evaluation.md index 767e76d..76298b4 100644 --- a/docs/privacy-and-evaluation.md +++ b/docs/privacy-and-evaluation.md @@ -77,10 +77,14 @@ only path-free added source IDs. It creates no refresh observation for new members. The explicit bounded applied operation appends changed bytes only for active same-member files in source-ID order and records current observations for successful changed, current, and same-member-missing -entries. Rename/removal decisions, root or origin rebind, automatic -retirement/deletion, adapters, indexing, and background -refresh remain deferred; a later member failure returns a path-free partial -result after earlier member commits. +entries. Complete removal reconciliation, root or origin rebind, automatic +retirement/deletion, adapters, indexing, and background refresh remain deferred; +a later member failure returns a path-free partial result after earlier member +commits. An explicit approved directory-member retirement operation can mark one +fresh-scan `missing` same-member source as logically removed with the existing +`user-requested` marker; it does not delete bytes, versions, bindings, +observations, journal state, or immutable membership. Already retired members +return an `already-removed` result without a write. One explicitly approved HTTPS URL can also become an initial managed CKB source. The existing controlled URL ingestion boundary validates public address @@ -160,8 +164,8 @@ canonical origin binding. Partial and legacy runtime-only imports have no directory membership evidence. The store has a read-only explicit bounded directory refresh preview, an explicit add-members operation, and an applied operation limited to existing active same-member changed files. Add-members -persists only unmatched accepted files as append-only new members; rename/removal -decisions, directory-root rebind, automatic retirement/deletion, background +persists only unmatched accepted files as append-only new members; complete +removal reconciliation, directory-root rebind, automatic retirement/deletion, background refresh, time-based freshness policy, moved-origin discovery, automatic duplicate resolution, normalized facts, or retrieval indexes. A read-only, one-CKB-scoped duplicate projection compares only latest diff --git a/docs/roadmap.md b/docs/roadmap.md index 31f953e..7364ea7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -182,7 +182,8 @@ directory-root binding plus immutable SHA-256 membership hashes for accepted files, while partial and legacy runtime-only imports have no directory membership evidence. The selected root and exact origins remain sensitive local state outside generic projections. Incremental removal/rename refresh, root -rebind, and member-retirement policy remain unimplemented. Membership is a +rebind, complete removal reconciliation, and broader member-retirement policy +remain unimplemented. Membership is a stable historical mapping captured at binding or explicit append time; later source version appends, explicit origin rebinding, or source retirement do not rewrite it. @@ -192,8 +193,8 @@ append unmatched accepted files as new managed file sources and immutable members in deterministic path order. Each candidate commits its source, version, origin binding, managed bytes, journal event, and membership atomically; later failures return path-free partial added IDs, and new members receive no -refresh observations. Removal, rename, root rebind, and member-retirement -policy remain deferred. +refresh observations. Complete removal reconciliation, rename, root rebind, and +broader member-retirement policy remain deferred. An explicit local bounded read-only refresh preview now revalidates the stored root, repeats the intake preflight, and returns path-free `current`, `changed`, `missing`, `retired`, or `origin-conflict` member states plus an aggregate count @@ -202,7 +203,13 @@ observation, or lifecycle writes; scan-level unreadable, unstable, limit, and extraction failures fail closed. Applied refresh is limited to existing active same-member changed files; rename/removal decisions, member-retirement lifecycle, directory-root rebind, automatic retirement/deletion, adapters, indexing, and -background refresh remain deferred. +background refresh remain deferred. An explicit approved directory-member +retirement operation can mark one active same-member `missing` source as +logically removed using the existing `user-requested` retirement marker after +latest-version, origin-revision, and chronology guards. It returns path-free +`removed` or `already-removed`; it does not delete bytes, versions, bindings, +observations, journal state, or immutable membership. Complete directory +reconciliation and physical cleanup remain deferred. An explicit bounded observation-only directory refresh now reuses one complete scan and atomically records path-free `current`, `changed`, and same-member `missing` observations with one shared timestamp. Retired, origin-conflict, and @@ -264,7 +271,8 @@ captured at binding or explicit append time and is not rewritten by later source versioning, explicit origin rebinding, or retirement. Directory rebind, rename/removal lifecycle, and incremental scan reconciliation remain deferred. The read-only preview, -bounded existing-member applied refresh, and explicit add-members operation are + bounded existing-member applied refresh, explicit add-members operation, and + explicit missing-member retirement are component implementations only; the v0.7 stage remains at component implementation. Journal records exclude origin paths, filenames, labels, checksums, source content, @@ -577,6 +585,7 @@ model above controls current stage claims. | Date | Change | Reason | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-08-23 | Added explicit bounded logical retirement of one missing directory member as the thirteenth #110 slice without advancing the v0.7 stage beyond component implementation | A fresh bounded scan can now approve one active same-member missing source for guarded insertion of the existing user-requested retirement marker; results distinguish logical removed from already-removed, preserve all bytes and immutable membership, and leave complete reconciliation, physical cleanup, adapters, indexing, and background refresh pending. | | 2026-08-23 | Added explicit bounded directory member addition as the twelfth #110 slice without advancing the v0.7 stage beyond component implementation | One complete scan of an existing binding can now append unmatched accepted files as independent managed sources and immutable hashed members in lexical path order, one candidate atomically at a time, with path-free added IDs and partial results; refresh observations, rename/removal, root rebind, retirement, adapters, indexing, and background refresh remain pending. | | 2026-08-23 | Added explicit applied bounded directory refresh for existing active same-member files as the eleventh #110 slice without advancing the v0.7 stage beyond component implementation | One complete local scan can now append changed bytes in deterministic source-ID order through guarded managed-file publication, persist current observations for successful changed/current/missing members, and return a path-free partial result after a later member failure; new-member persistence, reconciliation, lifecycle, adapters, indexing, and background refresh remain pending. | | 2026-08-23 | Added explicit bounded directory observation recording as the tenth #110 slice without advancing the v0.7 stage beyond component implementation | One complete local scan can now atomically persist path-free current/changed/same-member-missing observations for eligible active members with one checked-at timestamp; retired, origin-conflict, and new files remain report-only, and changed-byte application, new-member persistence, reconciliation, lifecycle, adapters, indexing, and background refresh remain pending. | diff --git a/docs/threat-model.md b/docs/threat-model.md index 759bba5..c763976 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -101,6 +101,14 @@ conditional requests; automatic duplicate resolution; indexes/retrieval; app/run CKB selection; CLI/desktop controls; deletion; cleanup/reconciliation; and complete backup/export/restore remain outside that boundary. +An explicit approved directory-member retirement operation accepts only one +active same-member `missing` source from a fresh bounded scan and records the +existing `user-requested` retirement marker under latest-version, +origin-revision, and chronology guards. Its path-free `removed` result means +logical retirement, not physical deletion; an already retired member returns +`already-removed` without a write. Bytes, versions, bindings, observations, +journal state, and immutable membership remain unchanged. + A separate explicit bounded observation-only directory scan reuses one complete traversal and atomically persists only eligible active same-member `current`/`changed`/`missing` observations. Retired, origin-conflict, and new @@ -149,7 +157,7 @@ threat-model review. They are not covered by the controls below. | Application to model provider | Candidate sources, context, prompts, and drafts | Fingerprinted desktop preflight, workspace-local safe acknowledgement metadata, fresh host-side verification before transmitting actions, `DataExposurePolicy`, provider/model/endpoint identity, bounded context, and run budgets | The flow is not yet cross-platform validated; provider retention and training behavior remain external facts | | Application to authenticated local agent runtime | Candidate context, provider session, local files, and process environment | Explicit user-session mode, empty temporary working directory, disabled tools/customizations/MCP/web access, bounded process IO, cancellation, structured-output validation, and no OAuth-token extraction | Vendor runtimes and subscription terms can change; Codex does not currently expose an enforceable output-token ceiling | | Application to approved URL | Source URL, local network reachability, fetched content, and provenance | `ingestUrl` requires approval, HTTPS, safe host resolution, manual redirect validation, time/size limits, supported text content, and successful extraction; CKB intake and explicit refresh publish changed exact response bytes and immutable local per-version URL provenance only after those gates pass; refresh rejects invalid scope/kind/retirement before fetching and records only URL-free inaccessible failure state | DNS rebinding, resolver/fetch races, remote tracking, malicious HTML, sensitive query strings, and future parser expansion require continuing tests; retirement can race an already approved in-flight fetch; redirect history and time-based readiness are not implemented | -| Application to approved local directory | Candidate files, local paths, traversal reachability, and aggregate intake size | Explicit local approval; real non-symlink root outside the CKB store; canonical realpath containment; lexical recursive traversal; depth, scanned-entry, accepted-file, aggregate-byte, and 20 MiB per-file limits; lstat mutation checks; no child-symlink following or special-entry opens; extraction before writes; skipped-entry counts; independent managed file sources; complete imports atomically persist a sensitive root binding plus immutable relative-path hashes; an explicit bounded read-only preview returns path-free member states and an aggregate new-source count; path-free generic failures | A complete import retains the canonical directory root and child origin paths only in sensitive local tables; partial and legacy runtime-only imports have no directory-root/membership binding (the “no binding” claim means no directory-root or membership binding); membership is a stable historical mapping and later source versioning, origin rebinding, or retirement does not rewrite it; an explicit add-members operation appends unmatched accepted files one candidate at a time with path-free added IDs and no new-member observations; the preview and scan-level unreadable/unstable/limit/extraction failures fail closed; same-user mutation races remain possible despite repeated checks; counts reveal limited directory shape; removals, renames, root rebinding, retirement, and incremental reconciliation are not tracked | +| Application to approved local directory | Candidate files, local paths, traversal reachability, and aggregate intake size | Explicit local approval; real non-symlink root outside the CKB store; canonical realpath containment; lexical recursive traversal; depth, scanned-entry, accepted-file, aggregate-byte, and 20 MiB per-file limits; lstat mutation checks; no child-symlink following or special-entry opens; extraction before writes; skipped-entry counts; independent managed file sources; complete imports atomically persist a sensitive root binding plus immutable relative-path hashes; an explicit bounded read-only preview returns path-free member states and an aggregate new-source count; path-free generic failures | A complete import retains the canonical directory root and child origin paths only in sensitive local tables; partial and legacy runtime-only imports have no directory-root/membership binding (the “no binding” claim means no directory-root or membership binding); membership is a stable historical mapping and later source versioning, origin rebinding, or retirement does not rewrite it; an explicit add-members operation appends unmatched accepted files one candidate at a time with path-free added IDs and no new-member observations; an explicit directory-member retirement operation can logically retire one fresh-scan missing same-member source without deleting evidence; the preview and scan-level unreadable/unstable/limit/extraction failures fail closed; same-user mutation races remain possible despite repeated checks; counts reveal limited directory shape; complete removal reconciliation, renames, root rebinding, automatic deletion, and incremental reconciliation are not tracked | | Application to portable-store structural inventory | Sensitive unknown filesystem entries and managed-source integrity | Explicit local query after referenced-blob validation; bounded count-only classification; no names/paths/content; no unknown symlink following, unknown-directory recursion, unknown-byte reads, mutation, or provider exposure | Counts reveal limited store shape; scan limits may produce incomplete results; structural categories cannot establish ownership or authorize cleanup | | Managed-write operation to internal ownership journal | Prospective ownership provenance and operation lifecycle | Append-only opaque intent before staging; resolved target before publication; monotonic publication and atomic-commit events; completion after staging cleanup; terminal non-owning no-op; opaque operation-derived staging names; no sensitive source metadata, cleanup token, approval, diagnostic, provider, or application projection | Same-user database tampering remains possible; legacy and unjournaled entries stay unknown; journal evidence alone cannot coordinate writers or authorize cleanup | | Application to local endpoint | Candidate data, credentials, and model output | Adapter contract, structured output validation, and explicit configuration | “Local” does not prove same-machine operation, privacy, identity, or trustworthy retention | @@ -250,8 +258,10 @@ than a live directory reconciliation map; source versioning, origin rebinding, and retirement do not rewrite them. The explicit bounded preview is read-only and path-free; the separate add-members operation appends only unmatched accepted files as new members, while the applied operation handles only -existing active same-member changed files. Neither operation infers renames, -decides removals, rebinds the root, or retires/deletes sources automatically. +existing active same-member changed files. The explicit retirement operation +handles only one approved missing member; none of these operations infers +renames, complete removal reconciliation, or root rebind, and no operation +physically deletes sources automatically. The structural inventory is a local application query, not a provider-facing projection or content-diagnostic workflow. Missing or corrupt referenced blobs diff --git a/packages/application/src/knowledge-base.test.ts b/packages/application/src/knowledge-base.test.ts index 620b0d8..70fb619 100644 --- a/packages/application/src/knowledge-base.test.ts +++ b/packages/application/src/knowledge-base.test.ts @@ -1297,6 +1297,259 @@ describe("candidate knowledge store application service", () => { } }); + it("logically retires a missing directory member and is idempotent without deleting evidence", async () => { + const directoryPath = join(temporaryParent, "retire-directory"); + const sourcePath = join(directoryPath, "missing.md"); + const content = "missing candidate evidence"; + await mkdir(directoryPath); + await writeFile(sourcePath, content, "utf8"); + let now = createdAt; + const service = createCandidateKnowledgeStoreService({ + generateId: (() => { + const ids = [ + "store-uuid", + "default-ckb-uuid", + "source-uuid", + "version-uuid", + "directory-id", + ]; + return () => ids.shift() ?? "unexpected-id"; + })(), + now: () => now, + }); + await service.initializeStore({ storeRoot }); + const imported = await service.importKnowledgeSourceDirectory({ + storeRoot, + knowledgeBaseId: "default-ckb-uuid", + directoryPath, + }); + if (imported.status !== "complete") throw new Error("expected complete directory import"); + + const beforeStore = await openCandidateKnowledgeStore(storeRoot); + const beforeState = { + source: await beforeStore.getCandidateKnowledgeSource("default-ckb-uuid", "source-uuid"), + versions: await beforeStore.listCandidateKnowledgeSourceVersions( + "default-ckb-uuid", + "source-uuid", + ), + origin: await beforeStore.getCandidateKnowledgeSourceOriginBinding( + "default-ckb-uuid", + "source-uuid", + ), + members: await beforeStore.listCandidateKnowledgeDirectoryMembers( + "default-ckb-uuid", + imported.directoryId, + ), + inventory: await beforeStore.inspectManagedCandidateKnowledgeFiles(), + }; + await beforeStore.close(); + await rm(sourcePath); + now = changedAt; + + const removed = await service.retireKnowledgeSourceDirectoryMember({ + storeRoot, + knowledgeBaseId: "default-ckb-uuid", + directoryId: imported.directoryId, + sourceId: "source-uuid", + approved: true, + }); + expect(removed).toEqual({ + directoryId: imported.directoryId, + sourceId: "source-uuid", + status: "removed", + checkedAt: changedAt, + retiredAt: changedAt, + reason: "user-requested", + }); + expect(Object.isFrozen(removed)).toBe(true); + expect(JSON.stringify(removed)).not.toContain(temporaryParent); + expect(JSON.stringify(removed)).not.toContain(content); + + const afterStore = await openCandidateKnowledgeStore(storeRoot); + const afterState = { + source: await afterStore.getCandidateKnowledgeSource("default-ckb-uuid", "source-uuid"), + versions: await afterStore.listCandidateKnowledgeSourceVersions( + "default-ckb-uuid", + "source-uuid", + ), + origin: await afterStore.getCandidateKnowledgeSourceOriginBinding( + "default-ckb-uuid", + "source-uuid", + ), + members: await afterStore.listCandidateKnowledgeDirectoryMembers( + "default-ckb-uuid", + imported.directoryId, + ), + inventory: await afterStore.inspectManagedCandidateKnowledgeFiles(), + retirement: await afterStore.getCandidateKnowledgeSourceRetirement( + "default-ckb-uuid", + "source-uuid", + ), + }; + await afterStore.close(); + expect({ ...afterState, retirement: undefined }).toEqual({ + ...beforeState, + retirement: undefined, + }); + expect(afterState.retirement).toEqual({ + sourceId: "source-uuid", + retiredAt: changedAt, + reason: "user-requested", + }); + + now = "2026-08-21T11:00:00.000Z"; + await expect( + service.retireKnowledgeSourceDirectoryMember({ + storeRoot, + knowledgeBaseId: "default-ckb-uuid", + directoryId: imported.directoryId, + sourceId: "source-uuid", + approved: true, + }), + ).resolves.toEqual({ + directoryId: imported.directoryId, + sourceId: "source-uuid", + status: "already-removed", + checkedAt: "2026-08-21T11:00:00.000Z", + retiredAt: changedAt, + reason: "user-requested", + }); + }); + + it("rejects unapproved, non-missing, conflicting, wrong-member, and failed scans generically", async () => { + const directoryPath = join(temporaryParent, "retire-rejections"); + const sourcePath = join(directoryPath, "selected.md"); + const replacementPath = join(temporaryParent, "replacement.md"); + const initialContent = "initial candidate evidence"; + const changedContent = "changed candidate evidence"; + await mkdir(directoryPath); + await writeFile(sourcePath, initialContent, "utf8"); + await writeFile(replacementPath, changedContent, "utf8"); + let now = createdAt; + const ids = [ + "store-uuid", + "default-ckb-uuid", + "source-uuid", + "version-uuid", + "directory-id", + "changed-version-uuid", + ]; + const ingestDirectory = vi.fn(ingestDirectoryImplementation); + const open = vi.fn(async (root: string) => openCandidateKnowledgeStore(root)); + const service = createCandidateKnowledgeStoreService({ + generateId: () => ids.shift() ?? "unexpected-id", + now: () => now, + ingestDirectory: ingestDirectory as never, + open: open as never, + }); + await service.initializeStore({ storeRoot }); + const imported = await service.importKnowledgeSourceDirectory({ + storeRoot, + knowledgeBaseId: "default-ckb-uuid", + directoryPath, + }); + if (imported.status !== "complete") throw new Error("expected complete directory import"); + ingestDirectory.mockClear(); + open.mockClear(); + + await expect( + service.retireKnowledgeSourceDirectoryMember({ + storeRoot, + knowledgeBaseId: "default-ckb-uuid", + directoryId: imported.directoryId, + sourceId: "source-uuid", + approved: false, + }), + ).rejects.toThrow( + "The selected candidate knowledge source directory member could not be removed.", + ); + expect(ingestDirectory).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + + now = changedAt; + await expect( + service.retireKnowledgeSourceDirectoryMember({ + storeRoot, + knowledgeBaseId: "default-ckb-uuid", + directoryId: imported.directoryId, + sourceId: "source-uuid", + approved: true, + }), + ).rejects.toThrow( + "The selected candidate knowledge source directory member could not be removed.", + ); + await writeFile(sourcePath, changedContent, "utf8"); + await expect( + service.appendKnowledgeSourceFileVersion({ + storeRoot, + knowledgeBaseId: "default-ckb-uuid", + sourceId: "source-uuid", + sourcePath, + }), + ).resolves.toMatchObject({ created: true }); + now = "2026-08-21T10:30:00.000Z"; + await expect( + service.retireKnowledgeSourceDirectoryMember({ + storeRoot, + knowledgeBaseId: "default-ckb-uuid", + directoryId: imported.directoryId, + sourceId: "source-uuid", + approved: true, + }), + ).rejects.toThrow( + "The selected candidate knowledge source directory member could not be removed.", + ); + await expect( + service.rebindKnowledgeSourceOrigin({ + storeRoot, + knowledgeBaseId: "default-ckb-uuid", + sourceId: "source-uuid", + sourcePath: replacementPath, + }), + ).resolves.toMatchObject({ status: "rebound" }); + await expect( + service.retireKnowledgeSourceDirectoryMember({ + storeRoot, + knowledgeBaseId: "default-ckb-uuid", + directoryId: imported.directoryId, + sourceId: "source-uuid", + approved: true, + }), + ).rejects.toThrow( + "The selected candidate knowledge source directory member could not be removed.", + ); + await expect( + service.retireKnowledgeSourceDirectoryMember({ + storeRoot, + knowledgeBaseId: "default-ckb-uuid", + directoryId: imported.directoryId, + sourceId: "missing-source", + approved: true, + }), + ).rejects.toThrow( + "The selected candidate knowledge source directory member could not be removed.", + ); + + ingestDirectory.mockRejectedValueOnce(new Error("scan failed")); + await expect( + service.retireKnowledgeSourceDirectoryMember({ + storeRoot, + knowledgeBaseId: "default-ckb-uuid", + directoryId: imported.directoryId, + sourceId: "source-uuid", + approved: true, + }), + ).rejects.toThrow( + "The selected candidate knowledge source directory member could not be removed.", + ); + + const reopened = await openCandidateKnowledgeStore(storeRoot); + await expect( + reopened.getCandidateKnowledgeSourceRetirement("default-ckb-uuid", "source-uuid"), + ).resolves.toBeUndefined(); + await reopened.close(); + }); + it("classifies an active member without an origin binding as an origin conflict", async () => { const sourcePath = "/selected/current.txt"; const normalized = successfulIngestion(sourcePath, { diff --git a/packages/application/src/knowledge-base.ts b/packages/application/src/knowledge-base.ts index 3434201..1a0fbba 100644 --- a/packages/application/src/knowledge-base.ts +++ b/packages/application/src/knowledge-base.ts @@ -143,6 +143,14 @@ export interface AddKnowledgeSourceDirectoryMembersCommand { readonly options?: DirectoryIngestionOptions; } +export interface RetireKnowledgeSourceDirectoryMemberCommand { + readonly storeRoot: string; + readonly knowledgeBaseId: string; + readonly directoryId: string; + readonly sourceId: string; + readonly approved: boolean; +} + export interface ImportKnowledgeSourceUrlCommand { readonly storeRoot: string; readonly knowledgeBaseId: string; @@ -398,6 +406,15 @@ export type AddKnowledgeSourceDirectoryMembersResult = | AddKnowledgeSourceDirectoryMembersCompleteResult | AddKnowledgeSourceDirectoryMembersPartialResult; +export interface RetireKnowledgeSourceDirectoryMemberResult { + readonly directoryId: string; + readonly sourceId: string; + readonly status: "removed" | "already-removed"; + readonly checkedAt: string; + readonly retiredAt: string; + readonly reason: "user-requested"; +} + export interface CandidateKnowledgeStoreService { readonly initializeStore: ( command: InitializeStoreCommand, @@ -439,6 +456,9 @@ export interface CandidateKnowledgeStoreService { readonly addKnowledgeSourceDirectoryMembers: ( command: AddKnowledgeSourceDirectoryMembersCommand, ) => Promise; + readonly retireKnowledgeSourceDirectoryMember: ( + command: RetireKnowledgeSourceDirectoryMemberCommand, + ) => Promise; readonly importKnowledgeSourceUrl: ( command: ImportKnowledgeSourceUrlCommand, ) => Promise; @@ -569,6 +589,12 @@ function addDirectoryMembersFailure(): Error { return new Error("The selected candidate knowledge source directory members could not be added."); } +function retireDirectoryMemberFailure(): Error { + return new Error( + "The selected candidate knowledge source directory member could not be removed.", + ); +} + function importUrlFailure(): Error { return new Error("The selected candidate knowledge source URL could not be imported."); } @@ -835,6 +861,7 @@ interface CollectedDirectoryRefreshMember { readonly latestVersion: CandidateKnowledgeSourceVersionRecord; readonly expectedOriginBoundAt?: string; readonly originRelation: CandidateKnowledgeDirectoryMemberOriginRelationRecord["relation"]; + readonly retirement?: CandidateKnowledgeSourceRetirementRecord; } interface CollectedDirectoryRefresh { @@ -1060,6 +1087,7 @@ async function collectDirectoryRefresh( ? {} : { expectedOriginBoundAt: relation.originBoundAt }), originRelation: relation.relation, + ...(retirement === undefined ? {} : { retirement }), }); } @@ -1157,6 +1185,23 @@ function addDirectoryMembersResult( }); } +function retireDirectoryMemberResult( + directoryId: string, + sourceId: string, + status: RetireKnowledgeSourceDirectoryMemberResult["status"], + checkedAt: string, + retirement: CandidateKnowledgeSourceRetirementRecord, +): RetireKnowledgeSourceDirectoryMemberResult { + return Object.freeze({ + directoryId, + sourceId, + status, + checkedAt, + retiredAt: retirement.retiredAt, + reason: retirement.reason, + }); +} + function validateRecordedDirectoryRefreshObservations( observations: readonly CandidateKnowledgeSourceRefreshObservationRecord[], entries: readonly { @@ -2852,6 +2897,83 @@ export function createCandidateKnowledgeStoreService( throw addDirectoryMembersFailure(); } }, + retireKnowledgeSourceDirectoryMember: async (command) => { + if (command.approved !== true) { + throw retireDirectoryMemberFailure(); + } + let storeRoot: string; + let knowledgeBaseId: string; + let directoryId: string; + let sourceId: string; + try { + storeRoot = requireStoreRoot(command.storeRoot); + knowledgeBaseId = requireText(command.knowledgeBaseId, "Candidate knowledge base id"); + directoryId = requireText(command.directoryId, "Candidate knowledge directory id"); + sourceId = requireText(command.sourceId, "Candidate knowledge source id"); + } catch { + throw retireDirectoryMemberFailure(); + } + + try { + return await useHandle( + () => resolved.open(storeRoot), + async (handle) => { + const collected = await collectDirectoryRefresh( + handle, + resolved, + storeRoot, + knowledgeBaseId, + directoryId, + undefined, + ); + const member = collected.members.find((candidate) => candidate.sourceId === sourceId); + if (member === undefined) { + throw retireDirectoryMemberFailure(); + } + if (member.status === "retired") { + if (member.retirement === undefined) { + throw retireDirectoryMemberFailure(); + } + validateRetirementRecord(member.retirement, sourceId); + return retireDirectoryMemberResult( + directoryId, + sourceId, + "already-removed", + collected.checkedAt, + member.retirement, + ); + } + if ( + member.status !== "missing" || + member.originRelation !== "same-member" || + member.expectedOriginBoundAt === undefined + ) { + throw retireDirectoryMemberFailure(); + } + const retirement = await handle.retireCandidateKnowledgeDirectoryMember( + knowledgeBaseId, + directoryId, + sourceId, + { + retiredAt: collected.checkedAt, + expectedVersionId: member.observedVersionId, + expectedOriginBoundAt: member.expectedOriginBoundAt, + }, + ); + validateRetirementRecord(retirement, sourceId); + return retireDirectoryMemberResult( + directoryId, + sourceId, + retirement.retiredAt === collected.checkedAt ? "removed" : "already-removed", + collected.checkedAt, + retirement, + ); + }, + ); + } catch { + throw retireDirectoryMemberFailure(); + } + }, importKnowledgeSourceUrl: async (command) => { if (command.approved !== true) throw importUrlFailure(); const storeRoot = requireStoreRoot(command.storeRoot); @@ -3376,6 +3498,8 @@ export const recordKnowledgeSourceDirectoryRefresh = export const applyKnowledgeSourceDirectoryRefresh = defaultService.applyKnowledgeSourceDirectoryRefresh; export const addKnowledgeSourceDirectoryMembers = defaultService.addKnowledgeSourceDirectoryMembers; +export const retireKnowledgeSourceDirectoryMember = + defaultService.retireKnowledgeSourceDirectoryMember; export const checkKnowledgeSourceOriginStatus = defaultService.checkKnowledgeSourceOriginStatus; export const refreshKnowledgeSourceFromOrigin = defaultService.refreshKnowledgeSourceFromOrigin; export const refreshKnowledgeSourceUrl = defaultService.refreshKnowledgeSourceUrl; diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index d10664e..81ecacb 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -218,6 +218,14 @@ export interface CandidateKnowledgeSourceRetirementRecord readonly sourceId: string; } +export interface CandidateKnowledgeDirectoryMemberRetirementInput { + readonly retiredAt: string; + /** The latest managed version observed during the directory scan. */ + readonly expectedVersionId: string; + /** The origin-binding revision observed during the directory scan. */ + readonly expectedOriginBoundAt: string; +} + export interface CandidateKnowledgeSourceVersionWriteResult { readonly source: CandidateKnowledgeSourceRecord; readonly version: CandidateKnowledgeSourceVersionRecord; @@ -369,6 +377,12 @@ export interface CandidateKnowledgeBaseStoragePort { sourceId: string, input: CandidateKnowledgeSourceRetirementInput, ) => Promise; + readonly retireCandidateKnowledgeDirectoryMember: ( + knowledgeBaseId: string, + directoryId: string, + sourceId: string, + input: CandidateKnowledgeDirectoryMemberRetirementInput, + ) => Promise; } export interface ContextSnapshotInput { @@ -3534,6 +3548,230 @@ export class SqliteStorage return result as CandidateKnowledgeSourceRetirementRecord; } + public async retireCandidateKnowledgeDirectoryMember( + knowledgeBaseId: string, + directoryId: string, + sourceId: string, + input: CandidateKnowledgeDirectoryMemberRetirementInput, + ): Promise { + this.ensureOpen(); + const normalizedKnowledgeBaseId = requireNonEmpty( + knowledgeBaseId, + "candidate knowledge base id", + ).trim(); + const normalizedDirectoryId = requireNonEmpty( + directoryId, + "candidate knowledge directory id", + ).trim(); + const normalizedSourceId = requireNonEmpty(sourceId, "candidate knowledge source id").trim(); + const retiredAt = requireTimestamp( + input.retiredAt, + "candidate knowledge directory member retirement retiredAt", + ); + const expectedVersionId = requireNonEmpty( + input.expectedVersionId, + "candidate knowledge directory member expected version id", + ).trim(); + const expectedOriginBoundAt = requireTimestamp( + input.expectedOriginBoundAt, + "candidate knowledge directory member expected origin boundAt", + ); + let result: CandidateKnowledgeSourceRetirementRecord | undefined; + this.database.transaction(() => { + this.requireActiveCandidateKnowledgeBase(normalizedKnowledgeBaseId); + const binding = this.database + .prepare( + `SELECT id, candidate_knowledge_base_id, root_path, bound_at + FROM candidate_knowledge_directory_bindings + WHERE candidate_knowledge_base_id = ? AND id = ?`, + ) + .get(normalizedKnowledgeBaseId, normalizedDirectoryId); + if (binding === undefined) { + throw new StorageValidationError("candidate knowledge directory binding was not found"); + } + if ( + rowString(binding, "id") !== normalizedDirectoryId || + rowString(binding, "candidate_knowledge_base_id") !== normalizedKnowledgeBaseId + ) { + throw new StorageValidationError("candidate knowledge directory binding is malformed"); + } + requireCanonicalAbsolutePath( + rowString(binding, "root_path"), + `candidate knowledge directory ${normalizedDirectoryId} root path`, + ); + const directoryBoundAt = requireTimestamp( + rowString(binding, "bound_at"), + `candidate knowledge directory ${normalizedDirectoryId} boundAt`, + ); + if (Date.parse(retiredAt) < Date.parse(directoryBoundAt)) { + throw new StorageValidationError( + "candidate knowledge directory member retirement must not precede directory binding", + ); + } + + const member = this.database + .prepare( + `SELECT directory_id, candidate_knowledge_base_id, source_id, relative_path_hash + FROM candidate_knowledge_directory_members + WHERE candidate_knowledge_base_id = ? + AND directory_id = ? + AND source_id = ?`, + ) + .get(normalizedKnowledgeBaseId, normalizedDirectoryId, normalizedSourceId); + if (member === undefined) { + throw new StorageValidationError("candidate knowledge directory member was not found"); + } + const memberRecord = candidateKnowledgeDirectoryMemberFromRow(member); + if ( + memberRecord.directoryId !== normalizedDirectoryId || + memberRecord.knowledgeBaseId !== normalizedKnowledgeBaseId || + memberRecord.sourceId !== normalizedSourceId || + !/^[0-9a-f]{64}$/.test(memberRecord.relativePathHash) + ) { + throw new StorageValidationError("candidate knowledge directory member is malformed"); + } + + const source = this.requireCandidateKnowledgeSource( + normalizedKnowledgeBaseId, + normalizedSourceId, + ); + if (source.kind !== "file") { + throw new StorageValidationError( + "candidate knowledge directory member is not a file source", + ); + } + const managed = this.database + .prepare( + `SELECT version.id + FROM candidate_knowledge_source_versions AS version + JOIN candidate_knowledge_managed_source_versions AS managed + ON managed.version_id = version.id + WHERE version.source_id = ? + LIMIT 1`, + ) + .get(normalizedSourceId); + if (managed === undefined) { + throw new StorageValidationError("candidate knowledge directory member is not managed"); + } + + const currentRow = this.database + .prepare( + "SELECT source_id, retired_at, reason FROM candidate_knowledge_source_retirements WHERE source_id = ?", + ) + .get(normalizedSourceId); + if (currentRow !== undefined) { + const current = candidateKnowledgeSourceRetirementFromRow(currentRow); + if (current.sourceId !== normalizedSourceId || current.reason !== "user-requested") { + throw new StorageValidationError( + "candidate knowledge source retirement marker is malformed", + ); + } + requireTimestamp( + current.retiredAt, + `candidate knowledge source ${normalizedSourceId} retirement retiredAt`, + ); + result = current; + return; + } + + this.requireCandidateKnowledgeSourceActive(normalizedSourceId); + const sourceCreatedAt = requireTimestamp( + source.createdAt, + `candidate knowledge source ${normalizedSourceId} createdAt`, + ); + if (Date.parse(retiredAt) < Date.parse(sourceCreatedAt)) { + throw new StorageValidationError( + "candidate knowledge directory member retirement must not precede source creation", + ); + } + const latestRow = this.database + .prepare( + `SELECT version.id, version.source_id, version.version, + version.parent_version_id, version.media_type, version.checksum, + version.size_bytes, version.created_at + FROM candidate_knowledge_source_versions AS version + JOIN candidate_knowledge_managed_source_versions AS managed + ON managed.version_id = version.id + WHERE version.source_id = ? + ORDER BY version.version DESC, version.id DESC + LIMIT 1`, + ) + .get(normalizedSourceId); + if (latestRow === undefined) { + throw new StorageValidationError( + "candidate knowledge directory member latest managed version was not found", + ); + } + const latestVersion = candidateKnowledgeSourceVersionFromRow(latestRow); + if (latestVersion.id !== expectedVersionId) { + throw new StorageConflictError( + "candidate knowledge directory member latest version changed", + ); + } + const latestVersionCreatedAt = requireTimestamp( + latestVersion.createdAt, + `candidate knowledge source ${normalizedSourceId} latest version createdAt`, + ); + if (Date.parse(retiredAt) < Date.parse(latestVersionCreatedAt)) { + throw new StorageValidationError( + "candidate knowledge directory member retirement must not precede latest version", + ); + } + + const relation = this.readCandidateKnowledgeDirectoryMemberOriginRelation( + normalizedKnowledgeBaseId, + normalizedDirectoryId, + normalizedSourceId, + ); + if (relation.relation !== "same-member" || relation.originBoundAt !== expectedOriginBoundAt) { + throw new StorageConflictError( + "candidate knowledge directory member origin revision changed", + ); + } + if (Date.parse(retiredAt) < Date.parse(expectedOriginBoundAt)) { + throw new StorageValidationError( + "candidate knowledge directory member retirement must not precede origin binding", + ); + } + + const observationRow = this.database + .prepare( + `SELECT source_id, checked_at + FROM candidate_knowledge_source_refresh_observations + WHERE source_id = ?`, + ) + .get(normalizedSourceId); + if (observationRow !== undefined) { + if (rowString(observationRow, "source_id") !== normalizedSourceId) { + throw new StorageValidationError( + "candidate knowledge source refresh observation is malformed", + ); + } + const observationCheckedAt = requireTimestamp( + rowString(observationRow, "checked_at"), + `candidate knowledge source ${normalizedSourceId} refresh checkedAt`, + ); + if (Date.parse(retiredAt) < Date.parse(observationCheckedAt)) { + throw new StorageValidationError( + "candidate knowledge directory member retirement must not precede refresh observation", + ); + } + } + + this.database + .prepare( + "INSERT INTO candidate_knowledge_source_retirements (source_id, retired_at, reason) VALUES (?, ?, ?)", + ) + .run(normalizedSourceId, retiredAt, "user-requested"); + result = { + sourceId: normalizedSourceId, + retiredAt, + reason: "user-requested", + }; + })(); + return result as CandidateKnowledgeSourceRetirementRecord; + } + public async getCandidateKnowledgeSourceRefreshObservation( knowledgeBaseId: string, sourceId: string, diff --git a/packages/storage/src/knowledge-store.test.ts b/packages/storage/src/knowledge-store.test.ts index 5c2bc4b..d5bfc0e 100644 --- a/packages/storage/src/knowledge-store.test.ts +++ b/packages/storage/src/knowledge-store.test.ts @@ -1129,6 +1129,199 @@ describe("portable candidate knowledge store", () => { await reopened.close(); }); + it("guards directory-member retirement and preserves immutable evidence", async () => { + const parent = await temporaryParent(); + const root = join(parent, "candidate-knowledge"); + const selectedDirectory = join(parent, "selected"); + const sourcePath = join(selectedDirectory, "first.md"); + const replacementPath = join(parent, "moved-first.md"); + const initialContent = "initial evidence"; + const updatedContent = "updated evidence"; + await mkdir(selectedDirectory); + await writeFile(sourcePath, initialContent, "utf8"); + await writeFile(replacementPath, updatedContent, "utf8"); + const store = await initializeCandidateKnowledgeStore(initialization(root)); + await store.createManagedCandidateKnowledgeFileSource( + { + id: "retire-source", + knowledgeBaseId: "ckb-default", + kind: "file", + displayName: "first.md", + createdAt: "2026-08-21T14:01:00.000Z", + }, + managedVersion(sourcePath, initialContent, { + id: "retire-version-first", + }), + ); + await store.createCandidateKnowledgeDirectoryBinding({ + id: "retire-directory", + knowledgeBaseId: "ckb-default", + rootPath: await realpath(selectedDirectory), + boundAt: "2026-08-21T14:02:00.000Z", + sourceIds: ["retire-source"], + }); + + await expect( + store.retireCandidateKnowledgeDirectoryMember( + "ckb-default", + "retire-directory", + "retire-source", + { + retiredAt: "2026-08-21T14:01:30.000Z", + expectedVersionId: "retire-version-first", + expectedOriginBoundAt: "2026-08-21T14:01:00.000Z", + }, + ), + ).rejects.toThrow(/must not precede directory binding/i); + await expect( + store.getCandidateKnowledgeSourceRetirement("ckb-default", "retire-source"), + ).resolves.toBeUndefined(); + + await writeFile(sourcePath, updatedContent, "utf8"); + await store.appendManagedCandidateKnowledgeFileVersion( + "ckb-default", + "retire-source", + managedVersion(sourcePath, updatedContent, { + id: "retire-version-second", + createdAt: "2026-08-21T14:03:00.000Z", + }), + ); + await expect( + store.retireCandidateKnowledgeDirectoryMember( + "ckb-default", + "retire-directory", + "retire-source", + { + retiredAt: "2026-08-21T14:04:00.000Z", + expectedVersionId: "retire-version-first", + expectedOriginBoundAt: "2026-08-21T14:01:00.000Z", + }, + ), + ).rejects.toThrow(/latest version changed/i); + + await store.rebindManagedCandidateKnowledgeFileOrigin("ckb-default", "retire-source", { + sourcePath: replacementPath, + mediaType: "text/markdown", + checksum: sha256(updatedContent), + sizeBytes: Buffer.byteLength(updatedContent), + boundAt: "2026-08-21T14:04:30.000Z", + }); + await expect( + store.retireCandidateKnowledgeDirectoryMember( + "ckb-default", + "retire-directory", + "retire-source", + { + retiredAt: "2026-08-21T14:05:00.000Z", + expectedVersionId: "retire-version-second", + expectedOriginBoundAt: "2026-08-21T14:01:00.000Z", + }, + ), + ).rejects.toThrow(/origin revision changed/i); + + await store.rebindManagedCandidateKnowledgeFileOrigin("ckb-default", "retire-source", { + sourcePath, + mediaType: "text/markdown", + checksum: sha256(updatedContent), + sizeBytes: Buffer.byteLength(updatedContent), + boundAt: "2026-08-21T14:05:30.000Z", + }); + await store.upsertCandidateKnowledgeDirectoryRefreshObservations( + "ckb-default", + "retire-directory", + { + checkedAt: "2026-08-21T14:07:00.000Z", + entries: [ + { + sourceId: "retire-source", + observedVersionId: "retire-version-second", + status: "missing", + expectedOriginBoundAt: "2026-08-21T14:05:30.000Z", + }, + ], + }, + ); + const beforeRetirement = { + source: await store.getCandidateKnowledgeSource("ckb-default", "retire-source"), + versions: await store.listCandidateKnowledgeSourceVersions("ckb-default", "retire-source"), + origin: await store.getCandidateKnowledgeSourceOriginBinding("ckb-default", "retire-source"), + members: await store.listCandidateKnowledgeDirectoryMembers( + "ckb-default", + "retire-directory", + ), + observation: await store.getCandidateKnowledgeSourceRefreshObservation( + "ckb-default", + "retire-source", + ), + inventory: await store.inspectManagedCandidateKnowledgeFiles(), + }; + + await expect( + store.retireCandidateKnowledgeDirectoryMember( + "ckb-default", + "retire-directory", + "retire-source", + { + retiredAt: "2026-08-21T14:06:00.000Z", + expectedVersionId: "retire-version-second", + expectedOriginBoundAt: "2026-08-21T14:05:30.000Z", + }, + ), + ).rejects.toThrow(/refresh observation/i); + const retirement = await store.retireCandidateKnowledgeDirectoryMember( + "ckb-default", + "retire-directory", + "retire-source", + { + retiredAt: "2026-08-21T14:08:00.000Z", + expectedVersionId: "retire-version-second", + expectedOriginBoundAt: "2026-08-21T14:05:30.000Z", + }, + ); + expect(retirement).toEqual({ + sourceId: "retire-source", + retiredAt: "2026-08-21T14:08:00.000Z", + reason: "user-requested", + }); + await expect( + store.retireCandidateKnowledgeDirectoryMember( + "ckb-default", + "retire-directory", + "retire-source", + { + retiredAt: "2026-08-21T14:09:00.000Z", + expectedVersionId: "stale-version", + expectedOriginBoundAt: "2026-08-21T14:01:00.000Z", + }, + ), + ).resolves.toEqual(retirement); + + expect({ + source: await store.getCandidateKnowledgeSource("ckb-default", "retire-source"), + versions: await store.listCandidateKnowledgeSourceVersions("ckb-default", "retire-source"), + origin: await store.getCandidateKnowledgeSourceOriginBinding("ckb-default", "retire-source"), + members: await store.listCandidateKnowledgeDirectoryMembers( + "ckb-default", + "retire-directory", + ), + observation: await store.getCandidateKnowledgeSourceRefreshObservation( + "ckb-default", + "retire-source", + ), + inventory: await store.inspectManagedCandidateKnowledgeFiles(), + }).toEqual(beforeRetirement); + await store.close(); + + const reopened = await openCandidateKnowledgeStore(root); + await expect( + reopened.getCandidateKnowledgeSourceRetirement("ckb-default", "retire-source"), + ).resolves.toEqual(retirement); + await expect( + reopened.listCandidateKnowledgeDirectoryMembers("ckb-default", "retire-directory"), + ).resolves.toEqual(beforeRetirement.members); + await reopened.close(); + }); + it("finds directory members by historical path hash after an origin rebind", async () => { const parent = await temporaryParent(); const root = join(parent, "candidate-knowledge"); diff --git a/packages/storage/src/knowledge-store.ts b/packages/storage/src/knowledge-store.ts index 2b7fceb..2d5d108 100644 --- a/packages/storage/src/knowledge-store.ts +++ b/packages/storage/src/knowledge-store.ts @@ -26,6 +26,7 @@ import { type CandidateKnowledgeDirectoryBindingRecord, type CandidateKnowledgeDirectoryMemberOriginRelationRecord, type CandidateKnowledgeDirectoryMemberRecord, + type CandidateKnowledgeDirectoryMemberRetirementInput, type CandidateKnowledgeDirectoryRefreshObservationBatchInput, type CandidateKnowledgeSourceInput, type CandidateKnowledgeSourceOriginBindingRecord, @@ -235,6 +236,12 @@ export interface CandidateKnowledgeStoreHandle extends CandidateKnowledgeBaseSto sourceId: string, input: CandidateKnowledgeSourceRetirementInput, ) => Promise; + readonly retireCandidateKnowledgeDirectoryMember: ( + knowledgeBaseId: string, + directoryId: string, + sourceId: string, + input: CandidateKnowledgeDirectoryMemberRetirementInput, + ) => Promise; readonly getManagedCandidateKnowledgeFilePath: ( knowledgeBaseId: string, sourceId: string, @@ -1772,6 +1779,20 @@ function createHandle( Object.freeze({ ...(await storage.retireCandidateKnowledgeSource(knowledgeBaseId, sourceId, input)), }), + retireCandidateKnowledgeDirectoryMember: async ( + knowledgeBaseId, + directoryId, + sourceId, + input, + ) => + Object.freeze({ + ...(await storage.retireCandidateKnowledgeDirectoryMember( + knowledgeBaseId, + directoryId, + sourceId, + input, + )), + }), createManagedCandidateKnowledgeFileSource: (source, initialVersion) => writeManagedCandidateKnowledgeFile(storage, root, { kind: "create",