/** Pure-data profile binding from a connector output to one entity type. */ interface ConnectorBindingDef { entityType: string; fields: Record; contentField?: string; } /** Durable connector-origin block carried in approved page frontmatter. */ interface DurableConnectorBlock { connectorId: string; connectorVersion: string; sourceUrl: string; fetchedAt: string; contentHash: string; idempotencyKey: string; externalFields: string[]; } /** * Non-default profile entity-page collector. * * For a custom (non-default) profile, every entity page's filename stem must * be a validated, slug-safe identity — there is no "raw stem" escape hatch as * there is on the default path. This module iterates a profile's declared * entity types, scans each directory through the SHARED `scanEntityDir` * primitive, and per page either mints a branded `EntityId` or records a * structured PROBLEM. * * Honest, graceful read path (problems, not throws): * - the collector NEVER throws on page data — a bad page yields a problem * record and is skipped, while its valid siblings still become pages; * - an INVALID (symlinked / confinement-failed) entity directory is surfaced * as an `invalid-directory` problem — never silently skipped, because the * spec forbids presenting a partial project as healthy; * - a MISSING directory is a benign empty entity type (no problem); * - a non-slug-safe stem → `non-slug-safe-filename` problem (with rename hint); * - a declared frontmatter `slug` that disagrees with the stem → * `slug-mismatch` problem; * - a valid page that violates the declared field contract (a missing * required field, or an enum value outside its declared set) → * `field-violation` problem; the page is STILL produced. * * The only thrown error is the `isDefaultProfile` guard — that is a programming * error (wrong collector), not page data. Default-profile collection NEVER comes * here; it goes through `collectRawWikiPages`, which keeps raw stems. */ /** The kinds of structured problem a non-default page/dir can exhibit. */ type EntityProblemKind = "invalid-directory" | "non-slug-safe-filename" | "slug-mismatch" | "field-violation"; /** * Profile pack type definitions (schemaVersion 1, v0). * * A profile pack describes the entity types a wiki compiles into: their * directories, fields, retrieval behaviour, and lifecycle. This is the v0 * surface — purely declarative. There is intentionally NO computed-field or * required-if logic yet; those are deferred to a later schema version. * * Identity is modelled with branded string types so that a raw filesystem * stem can never be mistaken for a validated slug or entity id at compile * time. The only way to obtain a SlugSafe or EntityId is through the * constructors in `./identity.js`. */ /** A string proven to match the slug-safe grammar (`^[a-z0-9][a-z0-9-]*$`). */ type SlugSafe = string & { readonly __slugSafe: unique symbol; }; /** A composed, validated `type/slug` entity identifier. */ type EntityId = string & { readonly __entityId: unique symbol; }; /** The supported scalar/array field types for a profile entity field. */ type FieldType = "string" | "number" | "integer" | "boolean" | "date" | "slug" | "enum" | "string[]" | "artifactRef" | "artifactRef[]"; /** * Declarative PRESENTATION hints for a text field: enough for a read surface to * build an external link generically, and nothing more. * * A CLOSED vocabulary rather than a URL template. A template would be an * author-supplied string a renderer interpolates into an href — executable * profile behaviour reaching a read surface — whereas these three name a * resolver the READER already knows, so the origin stays out of profile control. * An unknown value is rejected at load, so no renderer has to guess. */ type FieldFormat = "url" | "doi" | "arxiv"; /** Declarative definition of a single frontmatter field on an entity type. */ interface FieldDef { type: FieldType; /** * How a read surface may linkify this field's text. Valid only on `string` and * `string[]` (see `validateTitleField`'s neighbour in validate.ts): a format is * a hint about how to read text, and on any other type it would be config no * renderer could act on. */ format?: FieldFormat; required?: boolean; default?: unknown; enum?: string[]; min?: number; max?: number; /** For artifactRef fields: the declared artifact types this ref may point at (omitted = any). */ artifactTypes?: string[]; } /** * How an entity type participates in search and context retrieval. * * NOTE: a read-confidentiality control (`readExposure`) was intentionally * removed for v0 — nothing enforces it this slice, and shipping an unenforced * confidentiality field is false assurance. It returns in the phase that * actually enforces read-confidentiality. */ interface RetrievalDef { includeInSearch?: boolean; includeInContext?: boolean; defaultWeight?: number; } /** * A relation-count precondition on ENTERING a lifecycle state: THIS lifecycle's * entity must be an endpoint of at least `minCount` instances of `relationType` * on its `role` side (optionally narrowed to `otherTypes` on the opposite side). * * Declarative + load-validated in `./validate-relation-requirements.ts`; a * malformed or structurally-unsatisfiable requirement fails the profile LOAD. * ENFORCED at write time by `../relations/enforce-precondition.ts` (composed at * every live typed-page apply path) and re-checked as a standing invariant on * the read surfaces by `./relation-standing.ts`. */ interface RelationCountReq { /** A DECLARED relation type whose instances are counted. */ relationType: string; /** Whether THIS lifecycle's entity is the from- or to-endpoint of `relationType`. */ role: "from" | "to"; /** * Allowed entity types of the OTHER endpoint; omitted = any. Each entry must be * a declared entity AND a legal opposite-side endpoint of `relationType`. */ otherTypes?: string[]; /** * Allowed CURRENT lifecycle states of the OTHER endpoint for a relation to * count; omitted = any state (or no lifecycle at all). Enforcement is * FAIL-CLOSED: when set, an endpoint whose type declares no lifecycle, or * whose page carries no lifecycle-field value, does NOT qualify. Load-validated * in `./validate-relation-requirements.ts`: non-empty, entries unique, each a * declared lifecycle state of an allowed other endpoint type. */ otherStates?: string[]; /** Minimum required instance count; a finite integer >= 1. */ minCount: number; } /** * An artifact-existence precondition on ENTERING a lifecycle state: the entity's * declared `field` (an `artifactRef`/`artifactRef[]` field) must carry a pinned ref that * RESOLVES HEALTHY to an artifact of `artifactType`. EXISTENCE/health only — semantic * coverage is a NON-GOAL (OQ11), so there is deliberately NO `minCount`. Load-validated * in `./validate-artifact-requirements.ts` (M1/M3); ENFORCED at write time by * `../artifacts/enforce-precondition.ts`. */ interface ArtifactPreconditionReq { /** A declared `artifactRef`/`artifactRef[]` field of THIS entity whose ref must resolve healthy. */ field: string; /** The declared artifact type the ref must be. */ artifactType: string; } /** A state-machine lifecycle defined over one frontmatter field. */ interface LifecycleDef { field: string; initial: string; terminal: string[]; transitions: Record; transitionRequirements?: Record; /** * Optional per-state relation-count preconditions, keyed by the state being * ENTERED. Omitted-for-default: a lifecycle without this key behaves exactly as * before. Load-validated in `./validate-relation-requirements.ts`; ENFORCED at * write time by `../relations/enforce-precondition.ts` on every live typed-page * apply path, and re-evaluated against the CURRENT relation graph on the read * surfaces by `./relation-standing.ts` (standing-invariant drift detection). */ transitionRelationRequirements?: Record; /** * Optional per-state artifact-existence preconditions, keyed by the state being * ENTERED. Omitted-for-default. Load-validated in `./validate-artifact-requirements.ts`; * ENFORCED at write time by `../artifacts/enforce-precondition.ts`. See {@link ArtifactPreconditionReq}. */ transitionArtifactRequirements?: Record; } /** * Declarative definition of one typed relation type within a profile pack. * * A relation type declares its endpoints by entity-type id (`from`/`to`, each a * non-empty list of DECLARED entity types), a `direction` (`directed` keeps the * endpoint roles distinct; `symmetric` treats them as an unordered pair — * canonicalization of symmetric pairs is enforced at STORE time in a later * slice, not here), and optionally a set of typed `attributes` (reusing the same * {@link FieldDef} contract as entity fields) with a `requiredAttributes` * subset that must reference declared attribute keys. * * This is SCHEMA + VALIDATION only: there is no relation store or write path in * this slice. The DEFAULT profile declares no relations, so the whole block is * omitted-for-default and never appears in default output or digest. */ interface RelationTypeDef { /** Endpoint entity-type ids on the `from` side; each must be a declared entity. */ from: string[]; /** Endpoint entity-type ids on the `to` side; each must be a declared entity. */ to: string[]; /** `directed` keeps endpoint roles distinct; `symmetric` treats them as unordered. */ direction: "directed" | "symmetric"; /** Typed relation attributes, validated with the same {@link FieldDef} contract as fields. */ attributes?: Record; /** Attribute keys (subset of `attributes`) that must be present on a relation instance. */ requiredAttributes?: string[]; } /** One stage of a declarative workflow: what it reads/writes and an optional gate. */ interface WorkflowStageDef { /** Slug-safe stage id, unique within its workflow. */ id: string; /** Declared entity-type ids this stage reads. */ reads: string[]; /** Declared entity-type ids this stage writes. */ writes: string[]; /** * OPTIONAL declared artifact-type ids this stage may PRODUCE as an artifact * stage output (P-A). Omitted-for-default: a stage without this key produces no * artifacts, so default profiles stay byte-identical. Validated against the * profile's declared artifact types at load. */ artifactWrites?: string[]; /** Optional gate, `:` where kind ∈ {trust,human,agent}. */ gate?: string; /** * Prior stage ids this stage was renamed FROM; lets an in-flight run on an old * id be adapted rather than blocked. */ previousIds?: string[]; } /** A declarative, non-executable workflow definition. */ interface WorkflowDef { stages: WorkflowStageDef[]; /** Optional project-relative markdown projection target (validated in a later slice). */ projectionFile?: string; } /** The capability classes, ordinal: disabled < read-only < staged-write < trusted-write. */ type CapabilityClass = "disabled" | "read-only" | "staged-write" | "trusted-write"; /** The surfaces an action can be invoked through. */ type ActionSurface = "cli" | "sdk" | "mcp" | "viewer"; /** One declarative input field of an action's inputSchema. */ interface ActionInputField { type: "string" | "string[]" | "entityRef" | "number" | "boolean"; required?: boolean; default?: unknown; /** For type "entityRef": the declared entity types the ref may point to. */ entityTypes?: string[]; } /** A declarative workflow action: a shortcut resolving to a workflow operation. */ interface WorkflowActionDef { label: string; /** The declared workflow this action operates on. */ workflow: string; operation: "start" | "resume" | "advance" | "gate" | "cancel" | "fail" | "status" | "submit"; inputSchema?: Record; /** Per-surface REQUESTED capability (a request, not a grant). All 4 surfaces required. */ permissions: Record; /** A human:/agent: gate this action satisfies (operation "gate"). */ gate?: string; /** A trust: gate this action's write must pass. */ trustGate?: string; } /** Declarative definition of one entity type within a profile pack. */ interface EntityTypeDef { directory: string; titleField?: string; requiredFields?: string[]; fields?: Record; retrieval?: RetrievalDef; lifecycle?: LifecycleDef; /** * Optional shallowest-first per-record content-depth tiers for progressive * context revelation: each entry is either a declared field name of this entity * type OR the reserved {@link BODY_TIER_TOKEN}. OMITTED-for-default — a type * WITHOUT this key behaves exactly as today: its context primaries carry no * `contentTiers`, so default packs stay byte-identical. Load-validated in * `validate.ts`; consumed by the context projection in `context/content-tiers.ts`. */ contentTiers?: string[]; export?: { okfType?: string; }; } /** A profile-declared artifact type: a typed, content-addressed leaf file. */ interface ArtifactTypeDef { /** Leaf filename: safe-filename grammar + allowlisted extension (see src/artifacts/name.ts). */ fileName: string; /** v0 content kinds. */ contentKind: "json" | "text"; /** Inclusive UTF-8 byte ceiling, enforced on the handle before any read/hash. */ maxBytes: number; /** OPTIONAL partial scalar field-contract over top-level JSON object fields (json only). */ metadata?: Record; } /** A profile pack: the full declarative description of a wiki's entity types. */ interface ProfilePack { schemaVersion: 1; profileId: string; profileVersion?: string; displayName?: string; extends?: string[]; entities: Record; /** * Typed relation type declarations, keyed by slug-safe relation-type id. * OPTIONAL and omitted-for-default: a relation-less profile (including the * built-in default) carries no `relations` key, so its digest and output are * unchanged. See {@link RelationTypeDef}. */ relations?: Record; /** * Optional declarative workflow declarations, keyed by slug-safe workflow id. * OPTIONAL and omitted-for-default: a workflow-less profile (incl. the built-in * default) carries no `workflows` key, so its digest and output are unchanged. * See {@link WorkflowDef}. Validated fail-closed in validate.ts; never executable. */ workflows?: Record; /** * Optional declarative workflow-action declarations, keyed by a slug-safe DOTTED * id (`.`). OPTIONAL and omitted-for-default: an action-less * profile (incl. the built-in default) carries no `workflowActions` key, so its * digest and output are unchanged. Each action is a SHORTCUT that resolves to a * workflow operation; see {@link WorkflowActionDef}. Validated fail-closed in * validate.ts — SCHEMA + TYPES + VALIDATION only, with no authority model, * execution, or CLI surface in this slice. */ workflowActions?: Record; /** * Optional profile-declared artifact type declarations, keyed by slug-safe id. * OPTIONAL and omitted-for-default: an artifact-less profile (incl. the built-in * default) carries no `artifacts` key, so its digest and output are unchanged. * See {@link ArtifactTypeDef}. Validated fail-closed in validate.ts. */ artifacts?: Record; /** * Optional pure-data connector bindings, keyed by registered connector id. * OPTIONAL and omitted-for-default: connector-less profiles carry no * `connectors` key and never activate external fetch surfaces. */ connectors?: Record; } /** A profile resolved from disk (or built-in), with its source and digest. */ interface LoadedProfile { profile: ProfilePack; loadedFrom: string | null; digest: string; } /** * A reference to a single entity page on disk, carrying its validated slug * and minted id. * * EntityPageRef and EntityId are for NON-DEFAULT profiles only. Default-profile * pages keep their raw filesystem stems in RawWikiPage and never become an * EntityId — the default pipeline does not validate or mint identities. */ interface EntityPageRef { entityType: string; directory: string; slug: SlugSafe; id: EntityId; filePath: string; } /** * The PUBLIC surface DTO for a non-default profile entity page. * * Unlike the internal {@link EntityPage}, this NEVER carries an absolute * `filePath` — only a PROJECT-RELATIVE `path` (`${directory}/${slug}.md`) — so * read surfaces (`listPages`, JSON export) cannot leak machine-local paths. The * `body` is OPTIONAL and OMITTED entirely (not blanked to `""`) when the caller * did not request it, so an absent body is distinguishable from a genuinely * empty page. * * @experimental Shape may change in a future release. */ interface EntityPageView { entityType: string; directory: string; slug: string; id: string; /** Project-relative page path (`${directory}/${slug}.md`); never absolute. */ path: string; title?: string; frontmatter: Record; /** Markdown body; OMITTED (key absent) when bodies were not requested. */ body?: string; } /** * The PUBLIC surface DTO for a structured non-default-profile collector problem. * * Unlike the internal {@link EntityProblem}, this NEVER carries an absolute * `filePath` — only a PROJECT-RELATIVE `path` (`path.relative(root, filePath)`), * which is OMITTED entirely (key absent) for directory-level problems that have * no file. Carrying the structured `kind`/`entityType` (rather than a flattened * message string) lets a surface group, count, or filter problems, and keeps * repeated field violations distinguishable. * * @experimental Shape may change in a future release. */ interface EntityProblemView { /** * The problem kind. An entity-page/dir collector problem ({@link EntityProblemKind}); * `"relation-store"` for a fail-closed relation-store read (corrupt / too-new); * `"event-store"` for a fail-closed event-store read OR a broken/truncated * hash chain; * `"artifact-store"` for a hash-pinned artifactRef (page field or relation * attribute) that resolves to a non-`ok` health (dangling / bytes-tampered / * hash-mismatch / schema-invalid / unreadable / store-unavailable) — see * `./artifact-lint.js`; * `"lifecycle-relation-requirement-unmet"` for a page CURRENTLY in a gated * lifecycle state whose relation-count precondition NO LONGER holds against the * live relation graph (a standing-invariant violation); * `"lifecycle-relation-requirement-unverifiable"` when that standing check could * NOT read the relation store (cannot verify, not a confirmed violation). The * store-level and standing kinds are surfaced through the SAME problems channel * so a status/viewer envelope never reports a degraded project as silently healthy. */ kind: EntityProblemKind | "relation-store" | "event-store" | "artifact-store" | "lifecycle-relation-requirement-unmet" | "lifecycle-relation-requirement-unverifiable"; /** * Declared entity type the problem belongs to. ABSENT for a store-level * (`relation-store`) problem, which is not scoped to any entity type. */ entityType?: string; /** * Project-relative offending page path; ABSENT for directory-level problems. * Never absolute, and always `/`-separated on every platform — this is * portable content, not a filesystem argument. */ path?: string; message: string; } /** * Type definitions for the wiki schema layer. * * The schema layer turns llmwiki from a flat compiler pipeline into a shaped * knowledge system. It declares the kinds of pages a project supports * (`concept`, `entity`, `comparison`, `overview`) and the cross-link * expectations that lint and review enforce per kind. * * Types live in their own module so that compile, lint, CLI, and tests can * depend on the schema vocabulary without pulling in YAML/JSON loaders. */ /** All page kinds the schema layer recognises. */ type PageKind = "concept" | "entity" | "comparison" | "overview"; /** * Type definitions for the wiki linter. * Defines the shape of lint results, summaries, and rule functions * used across all lint rules and the orchestrator. */ interface LintResult { rule: string; severity: "error" | "warning" | "info"; file: string; message: string; line?: number; /** * Declared entity type a finding belongs to, set only by profile-aware * checks over non-default entity pages. Default-profile findings omit it, * so the field is absent (not `undefined`) on the default lint output and * the frozen parity golden stays byte-identical. */ entityType?: string; } interface LintSummary { errors: number; warnings: number; info: number; results: LintResult[]; } /** * Pure review-policy evaluation. * * The compile pipeline produces review signals from the final rendered page * (confidence/contradictions from frontmatter plus independently-computed * schema/provenance violations). This module contains only the deterministic * policy decision: given normalized project policy and those signals, return * the structured reasons that require the page to be held for review. */ /** * Closed set of review-policy reasons that fire from the compile pipeline and * are stored on review candidates / surfaced in CLI output. * * The Trust Guard's staged-write surface widens this into an OPEN union * (`HeldReasonCode` in `src/trust/staged-change.ts`) that keeps these literals * as a strict subset and adds trust-routing codes; this stays the narrow, * canonical policy set so the candidate store and CLI keep an exhaustive union. */ type PolicyHeldReasonCode = "low-confidence" | "contradicted" | "schema-violating" | "provenance-violating" | "all" | "manual-review-requested" | "imported-okf" | "connector-fetched"; /** The composed write decision for a proposed write. */ type TrustDecision = "allow" | "allow-with-warning" | "stage-for-review" | "quarantine" | "deny"; /** * Qualified page-id grammar helpers. * * A `PageId` is a `/` string that uniquely addresses a * wiki page across all namespaces (concepts, queries, and any declared entity * type). The grammar splits on the FIRST `/` only. * * ## Namespace rules * - Must be slug-safe: `^[a-z0-9][a-z0-9-]*$` (same as entity-type ids). * - Reserved namespaces are `concepts` and `queries` (the DEFAULT wiki dirs). * * ## Page-part rules * - Non-empty and not `.` or `..`. * - Must NOT contain `/`, `\`, `:`, or NUL — all path-dangerous. * - MAY contain spaces, Unicode, and `#` (raw DEFAULT stems preserve these). * * ## Relation to EntityId * A typed `EntityId` (`/`, from `src/profile/identity.ts`) * is structurally a `PageId` with a slug-safe page-part (a slug). The * `parseEntityId` / `entityId` helpers from identity.ts are the narrower * surface for fully-slug-safe entity pages; `parseQualifiedPageId` is the * wider gate that also accepts raw DEFAULT stems (spaces, Unicode, `#`). */ /** * A qualified page identity: `/`. * * The namespace is slug-safe (`^[a-z0-9][a-z0-9-]*$`). The page-part may * contain spaces, Unicode, and `#` but not `/`, `\`, `:`, or NUL. * Split on the FIRST `/` only — use {@link parseQualifiedPageId} to * decompose and validate. */ type PageId$1 = string; /** * Degrade-aware v3 read pipeline (PR4D Task D3, spec §4.6/§4.7/§4.8). * * The surface loaders return an {@link EmbeddingLoadOutcome} — never a bare * store-or-null — so a consumer ALWAYS sees WHY semantic retrieval contributed * nothing (a v2/older store is degraded-on-read with a structured * `embedding-index-outdated` warning; lexical retrieval still works). * * READ-ONLY invariant: this module NEVER writes, migrates, or prunes the store. * Stale (not-live / hash-mismatched) ids are reported in `stalePageIds`; the * actual pruning happens only on the next write (D7). * * The retrieval pipeline (§4.6 steps 3-5): * 3. cheap identity prefilter — drop store entries whose `pageId` is not a * live page (`buildLiveIdSet`) and whose surface flag excludes it, BEFORE * any scoring; * 4. score the survivors in memory (cosine); * 5. walk the ranked candidates filling results — per candidate (score order) * resolve via the live registry + verify content freshness, keep a fresh * hit, drop+warn a stale one, stop at K fresh hits or pool exhaustion. This * prevents a stale high-score hit from crowding out a lower-score fresh one. * * S12 rehydration: output `title`/`summary`/`text` come from the LIVE file (via * the registry loaders), never the store's cached copies — the cached values * exist only for migration/reuse and are NOT a tamper boundary (S8). */ /** Structured warning surfaced in the outcome the consumer actually reads (S6). */ interface EmbeddingWarning { code: "embedding-index-outdated" | "embedding-store-unavailable" | "embedding-entry-stale"; message: string; } /** * Page-reading utilities for llmwiki. * * Exposes `readPageRecord`, which locates a wiki page by slug across the * priority-ordered page directories (concepts first, then queries), parses * its frontmatter, and returns a structured `PageRecord`. Orphaned pages are * silently skipped to match the query pipeline's behaviour. * * This module is shared between the MCP tool layer and the in-process SDK so * both consumers work from identical read semantics. */ /** Shape returned by readPageRecord and search_pages for each matching page. */ interface PageRecord { slug: string; title: string; summary: string; body: string; } /** * @file src/trust/journal-health-warning.ts * @description The SHARED read-surface mapper for project-mutation journal health. Every * content-exposing read surface (status, lint, viewer snapshot, JSON export, * SDK list/search/context) must SURFACE a `pending`/`unavailable` journal so an * agent or user is never silently served partial post-crash state. This module * owns the one place that turns the read-only {@link journalHealth} verdict into * a neutral `{ code, message }` warning, and each surface threads it through its * OWN existing warning channel (mirroring how `relation-store-unavailable` / * `embedding-index-outdated` are surfaced): * - `pending` → `incomplete-compile` ("a prior compile did not finish"); * - `unavailable` → `journal-unavailable` (a distinct tamper/corruption signal); * - `ok` → `null` (adds NOTHING, so a clean project stays byte-identical). * * Like {@link journalHealth}, this is purely a read — it never writes, replays, * prunes, locks, or creates `.llmwiki`. Threading it into a read surface must * keep that surface read-only. */ /** Stable warning code for a `pending` journal (an incomplete compile to recover). */ declare const INCOMPLETE_COMPILE_CODE: "incomplete-compile"; /** Stable warning code for an `unavailable` journal (tampered/corrupt — distinct from pending). */ declare const JOURNAL_UNAVAILABLE_CODE: "journal-unavailable"; /** The two journal-health warning codes a read surface may surface. */ type JournalWarningCode = typeof INCOMPLETE_COMPILE_CODE | typeof JOURNAL_UNAVAILABLE_CODE; /** * A neutral read-surface warning: a stable, scriptable `code` plus a * human-readable `message`. The SHARED element type every read surface's * `warnings[]` channel carries, so independent signals (journal health, * pending-embedding refresh) coexist in one array. {@link JournalWarning} * narrows `code` to the journal codes; other mappers (e.g. * `pendingEmbeddingsWarning`) emit their own `code` under this same shape. */ interface ReadSurfaceWarning { code: string; message: string; } /** A neutral journal-health warning: a journal code plus human-readable message. */ interface JournalWarning extends ReadSurfaceWarning { code: JournalWarningCode; } /** * Semantic and LLM-based page retrieval for llmwiki (v3 pageId pipeline). * * Exports `pickSearchRefs`, which resolves relevant pages for a question through * the v3 read pipeline ({@link loadEmbeddingsForSearch} → chunk-level, then * page-level {@link findRelevantPagesV3}), then falls back to LLM-driven * selection over LIVE, surface-eligible, pageId-keyed candidates (NOT the * rendered `index.md` — see {@link selectFallbackRefs}). Every hit carries its * qualified `pageId`, so a concept `foo` and a query `foo` are distinct, and a * typed page surfaces under its `EntityId`. A degraded (non-v3 / unavailable) * store yields a structured warning instead of silently contributing nothing. * * `pickSearchSlugs` is the bare-slug compatibility shim (the legacy contract); * `loadSelectedRefs` rehydrates refs to full records from the LIVE files via the * confined registry loader (never store-cached text — S12). */ /** * A warning surfaced on a search result: an embedding-degrade signal OR the * shared journal-health warning (`incomplete-compile` / `journal-unavailable`). * The two share a `{ code, message }` shape; a healthy, fully-compiled project * surfaces neither, so the default search warnings list stays empty. */ type SearchWarning = EmbeddingWarning | JournalWarning; /** A selected page reference carrying its qualified identity plus derived slug. */ interface SelectedPageRef { pageId: PageId$1; slug: string; title: string; /** Whether the ref came from chunk-level, page-level, or index selection. */ kind: "chunk" | "page" | "index"; } /** * Lifecycle state of a concept or page's provenance. * - `extracted`: drawn directly from a source document. * - `merged`: synthesised from multiple sources during compilation. * - `inferred`: produced by the model from context, not directly cited. * - `ambiguous`: sources disagree or evidence is conflicting. * - `imported`: brought in from an external OKF bundle (durable origin marker * that survives review approval; never produced by local compilation). */ type ProvenanceState = "extracted" | "merged" | "inferred" | "ambiguous" | "imported"; /** * Reference to another concept that contradicts the current one. * The slug points to the contradicting wiki page. */ interface ContradictionRef { slug: string; reason?: string; } /** Structured result returned by the compile pipeline. */ interface CompileResult { compiled: number; skipped: number; deleted: number; concepts: string[]; pages: string[]; errors: string[]; /** Candidate IDs created by --review or policy-held compile outputs. */ candidates?: string[]; /** Structured review split for candidates created by the compile run. */ review?: { held: ReviewedCandidateRef[]; forced: ReviewedCandidateRef[]; }; } /** Candidate reference returned in CompileResult.review. */ interface ReviewedCandidateRef { id: string; slug: string; reasons: PolicyHeldReasonCode[]; } /** A single chunk citation surfaced as part of a query result. */ interface ChunkCitation { /** * Qualified parent-page id (`/`) — keeps same-slug chunks * (`concepts/foo` vs `papers/foo`) distinct in provenance/reasoning/debug. */ pageId: PageId$1; slug: string; title: string; chunkIndex: number; score: number; text: string; } /** Diagnostic snapshot of how the retrieval pipeline picked context. */ interface RetrievalDebug { /** Pages selected after collapsing chunks to their parent qualified pageId. */ pages: Array<{ pageId: PageId$1; score: number; }>; /** Top-ranked chunks before the page-collapse step. */ chunks: ChunkCitation[]; /** True when chunk-level entries drove the selection (vs. page-level fallback). */ usedChunks: boolean; /** True when reranking reordered the initial semantic ranking. */ reranked: boolean; } /** A structured warning surfaced in a {@link QueryResult} (S6). */ interface QueryWarning { code: string; message: string; } /** Structured result returned by the query pipeline. */ interface QueryResult { answer: string; /** * Legacy DERIVED display field: the bare page-part of each selected * {@link pageIds} entry (via `slugFromPageId`). Kept populated for back-compat * with consumers that read slugs; prefer {@link pageIds}/{@link refs} as the * canonical, collision-free identity (a typed `papers/foo` and a concept `foo` * share the slug `foo` but are DISTINCT pageIds). */ selectedPages: string[]; /** * Canonical qualified ids the answer was grounded on (`/`), * deduped and order-preserved. Carries the typed namespace so a `papers/foo` * hit grounds on `wiki/papers/foo.md`, never `wiki/concepts/foo.md`. */ pageIds: PageId$1[]; /** The selected page refs (qualified id + slug + title + selection kind). */ refs: SelectedPageRef[]; reasoning: string; saved?: string; /** Populated when the query was run in debug mode. */ debug?: RetrievalDebug; /** * Embedding-load degrade warnings surfaced in the result payload (S6) rather * than only logged: an outdated (non-v3) or unavailable index degrades query * to lexical/index selection and reports `embedding-index-outdated`. Omitted * (key absent) when there are no warnings, so a healthy query is unchanged. */ warnings?: QueryWarning[]; } /** Source type tag persisted in frontmatter to describe the ingest origin. */ type SourceType = "web" | "file" | "image" | "pdf" | "transcript"; /** Outcome of a source write: a new file, a content change, or a no-op. */ type WriteStatus = "created" | "updated" | "unchanged"; /** Structured result returned by the ingest pipeline. */ interface IngestResult { filename: string; charCount: number; truncated: boolean; source: string; /** Detected source type; undefined for legacy results produced before this field was added. */ sourceType?: SourceType; /** Whether the source file was created, updated (content changed), or unchanged (no-op). */ writeStatus: WriteStatus; } /** * Provenance helpers for `llmwiki context`. * * Slice 4 ships two related pieces of work: * 1. Flatten `ViewerPage.citations` (`ClaimCitation[]`, each with one * or more `SourceSpan` entries) into the documented * `ContextPrimary.citations[]` shape: one object per span, * `file`/`start`/`end` lifted from `span.lines` when present, * paragraph-only citations omit `start`/`end`, de-duped by * `(file, start, end)`, preserved in first-seen document order * (plan §Provenance And Source Windows). * 2. Materialize bounded `ContextSourceWindow[]` for `--include-sources` * by reading short line ranges out of `sources/`. Path-confined: * traversal, absolute paths, and symlink escapes are rejected. * Only claim-level spans (`lines` populated) become windows; * paragraph-only citations are intentionally skipped because the * caller asked for SPECIFIC line context, not whole files. * * Citation flattening is the inner contract for `primary[].citations`; * source-window materialization is the outer guard rail for * `--include-sources` per-pack and per-window caps. */ /** * Flat citation shape consumed by `ContextPrimary.citations[]` AND by * the JSON export's `ExportPage.citations[]`. Exported so both * surfaces share one normalized shape rather than drifting — consumers * can reuse the same flattening rule across `llmwiki context` and * `llmwiki export`. */ interface FlatCitation { file: string; start?: number; end?: number; } /** * Manages .llmwiki/state.json — the persistent compilation state that tracks * source file hashes and their compiled concepts. Enables incremental * compilation by detecting which sources have changed since last compile. * * Uses atomic writes (write to .tmp, then rename) to prevent corruption from * interrupted processes (crash-consistent; not guaranteed durable across power * loss, as there is no fsync before the rename). * * VERSION GUARD (Phase 2, v2-aware reads): {@link KNOWN_STATE_VERSION} is the * highest schema version this build understands. {@link readStateClassified} * classifies a read into a {@link StateStatus} — `ok` / `missing` / `corrupt` / * `too-new` — without side effects, so read-only surfaces (freshness, lint, * view, export) can branch on the outcome. A `too-new` file (one whose `version` * exceeds the known version) is the FAIL-CLOSED case: the parsed state is carried * intact, nothing is written, and the recovering {@link readState} throws * {@link StateTooNewError} rather than starting fresh — which would clobber the * forward-incompatible layout on the next write. A `corrupt` (unparseable) file * is backed up to `.bak` and recovered as empty state instead. */ /** * Readability classification of `.llmwiki/state.json`, shared by every * read-only surface so the fail-closed `too-new` outcome is represented * uniformly: * - ok = parsed and within the known schema range * - missing = no file * - corrupt = unparseable * - too-new = parsed but `version` exceeds {@link KNOWN_STATE_VERSION}; the * parsed state is carried (never reset) and nothing is written to disk. */ type StateStatus = "ok" | "missing" | "corrupt" | "too-new"; /** * Types for the computed source-freshness layer. * * Freshness is derived on demand from the filesystem + state.json and is never * persisted. `FreshnessSnapshot` is built once per command/viewer snapshot and * shared by every consumer (lint, export, MCP, context, viewer). */ /** A page's computed freshness on the source-derived axis. */ type FreshnessStatus = "fresh" | "stale" | "orphaned" | "unverified"; /** * Shared types for the llmwiki export subsystem. * * ExportPage is the normalised in-memory representation of a wiki page used * by every export format. It is derived from the page's YAML frontmatter plus * the wikilink graph extracted from the body. * * Trust-adjacent fields (`advisoryConfidence`, `provenanceState`, * `contradictedBy`) are surfaced as **advisory metadata only** — once the * export crosses into any downstream storage (Atomic Memory or otherwise), * those fields become mutable and lose their cryptographic tie to this * export. Consumers should treat them as the compiler's estimate at * export time, not as runtime guarantees. */ /** * Flat citation shape exported alongside each page. Identical to the * normalized `FlatCitation` used by `llmwiki context` so adapters that * consume both surfaces share one shape. Paragraph-only citations omit * `start` and `end`; claim-level citations carry the parsed line range. */ type ExportCitation = FlatCitation; /** Snapshot of an imported doc's original OKF frontmatter, captured at import. Present ONLY on imported pages; drives verbatim re-export of foreign frontmatter. */ interface XOkfSnapshot { /** Raw OKF `type` when it wasn't a known llmwiki kind (absent for known kinds). */ type?: string; /** Bundle-relative source path of the original OKF doc; durable across approval, for diagnosis. */ okfPath?: string; /** Full original OKF frontmatter, verbatim. */ originalFrontmatter: Record; } /** * Which wiki/ subdirectory a page lives in. * * Intentionally distinct from the schema layer's `PageKind` * (concept/entity/comparison/overview) — this is a filesystem location, not * a semantic typology. Renaming avoids field collision when JSON export and * schema metadata are consumed by the same downstream tooling. */ type PageDirectory = "concepts" | "queries"; /** A fully-resolved wiki page ready for export serialisation. */ interface ExportPage { /** Human-readable page title (from frontmatter). */ title: string; /** Filesystem slug (filename without .md). */ slug: string; /** Whether this page came from wiki/concepts or wiki/queries. */ pageDirectory: PageDirectory; /** * Project-relative path to the source markdown file, e.g. * `wiki/concepts/retrieval.md`. Surfaced for the bridge so downstream * adapters can deep-link without reconstructing the path themselves. */ path: string; /** One-line page summary (from frontmatter). */ summary: string; /** Source filenames cited in the page body. */ sources: string[]; /** Taxonomy tags (from frontmatter). */ tags: string[]; /** * ISO-8601 creation timestamp, read verbatim from frontmatter. ABSENT when the * page declares none — the export never substitutes its own run time, for the * same reason it omits an unset {@link ExportPage.kind}: an invented value is * indistinguishable from a recorded one once it reaches a consumer. * * Absent rather than `""` because every writer renders this field, and an * empty string is not "no date" — it is an assertion that the date is the * empty string. `"dateCreated": ""` is schema-invalid JSON-LD a consumer must * special-case, and `created: | updated:` in llms.txt reads as a rendering * fault. A writer that cannot state a date declines to state one. */ createdAt?: string; /** * ISO-8601 last-updated timestamp: the page's `updatedAt`, falling back to * {@link ExportPage.createdAt} (a saved query declares only the latter, and * `query --save` rewrites the whole file on every save, so there `createdAt` * *is* the last-written time). ABSENT when the page declares neither, for the * reason above. */ updatedAt?: string; /** Slugs of other pages this page links to via [[wikilinks]]. */ links: string[]; /** Full markdown body (without frontmatter). */ body: string; /** * Optional typed page kind from frontmatter. Defaults to "concept" in * downstream consumers when absent — the export omits the field if no * `kind` was set on the wiki page rather than fabricating a default. */ kind?: PageKind; /** Original OKF frontmatter snapshot when this page was imported from a foreign bundle; absent for native pages. */ xOkf?: XOkfSnapshot; /** Host-authored connector-origin metadata from `x-llmwiki.connector`; absent for native pages. */ connectorOrigin?: DurableConnectorBlock; /** * Compiler's confidence estimate at export time. Advisory only — * once imported into any downstream store this field is mutable and * not cryptographically bound to the export. */ advisoryConfidence?: number; /** Lifecycle state from the compiler's provenance metadata. Advisory only. */ provenanceState?: ProvenanceState; /** Other pages flagged as contradicting this one. Advisory only. */ contradictedBy?: ContradictionRef[]; /** * Claim citations from the page body, flattened to the shared bridge * shape. One entry per `^[file:start-end]` span. Multi-source markers * (`^[a.md, b.md]`) expand into multiple entries. Paragraph-only * citations carry no line range. */ citations: ExportCitation[]; /** * Prior external IDs this page was known by (e.g. before a slug * rename). Downstream importers treat any matching alias as an * upsert target so renamed pages do not orphan their prior memory * record. */ aliases?: string[]; /** * Advisory per-page source-freshness, computed at export time from * `.llmwiki/state.json` + the current `sources/`. A snapshot, not a * guarantee. The export is active-page-only, so this is `fresh`, `stale`, * or `unverified` — never `orphaned` (orphaned pages are dropped from the * export and surfaced by lint/the viewer instead). */ freshnessStatus: FreshnessStatus; /** True when the page is disputed by another page (`contradictedBy` non-empty). */ contradicted: boolean; /** True when the page is explicitly archived (`archived: true` frontmatter). */ archived: boolean; /** * Deterministic SHA-256 (hex) of {@link ExportPage.body}. Lets a * downstream auditor (export provenance) detect content drift and verify that an * imported page still matches what the compiler exported, without * re-reading the markdown. Stable for identical bodies. */ contentHash: string; /** * SHA-256 hashes of the source files this page derived from — the same * per-source digests the compiler records in `.llmwiki/state.json` for * change detection. Resolved from the page's `sources` list; ordered and * de-duplicated. Empty when a page has no recorded sources (e.g. seed * pages). Lets an auditor tie a page back to exact source bytes. */ sourceHashes: string[]; /** * Model id that produced this page's current content, stamped into the * page's frontmatter at compile time (export provenance). Unlike an export-time env * read, this is true per-page lineage: a page compiled by model A keeps * `modelId: A` even if the exporter's env later points at model B. Absent * for pages compiled before provenance stamping shipped. */ modelId?: string; /** * Named prompt-contract version the page was compiled under (export provenance), * stamped at compile time. Absent for pre-provenance pages. */ promptVersion?: string; /** * Prompt modifiers the page was compiled under, as sorted `key=value` pairs * (export provenance). Absent when the run selected none, and absent for * pages compiled before this was stamped. `promptVersion` names the prompt * implementation and is identical either way; this is what separates them. */ promptModifiers?: string[]; } /** * Path-safe page access primitives for the llmwiki in-process SDK. * * Exposes two public functions: * - `getPage(root, ref)` — fetch a single page by directory + slug; returns * the full `Page` shape (body included) or null when the file is absent. * - `listPages(root, options)` — scan both page directories, read each page's * body so wikilinks can be extracted, apply archive/orphan filters, sort, * and return a cursor-paged slice. * * Design notes: * - `links` are derived from the Markdown **body** via `extractWikilinkSlugs`, * NOT from frontmatter. * - `archived` and `orphaned` are boolean **frontmatter** flags. * - `scanWikiPages` returns `{ slug, meta }` only (no body), so `listPages` * always re-reads each file to extract body links even when `includeBody` * is false. * - Path safety is enforced at `getPage` entry via `assertSafeSlug`; symlink * confinement is handled at a lower level by `scanWikiPages`. */ /** A reference to a specific page by its directory and slug. */ interface PageRef { pageDirectory: PageDirectory; slug: string; } /** A fully-resolved in-memory representation of a single wiki page. */ interface Page { slug: string; pageDirectory: PageDirectory; title: string; summary: string; tags: string[]; /** Slugs of pages linked via `[[wikilinks]]` in the body. */ links: string[]; createdAt?: string; updatedAt?: string; /** True when frontmatter contains `orphaned: true`. */ orphaned: boolean; /** True when frontmatter contains `archived: true`. */ archived: boolean; /** Full markdown body, present only when `includeBody` is true or via `getPage`. */ body?: string; } /** Options for filtering and paginating `listPages`. */ interface ListPagesOptions { cursor?: string; limit?: number; includeBody?: boolean; includeArchived?: boolean; includeOrphaned?: boolean; /** * Opaque continuation cursor for the ADDITIVE profile entity section ONLY. * Drives the entity window independently of the legacy `cursor` (which scopes * `pages`), so the entity batch is never re-sliced or re-sent by legacy * paging. Uses the SAME `limit` as the legacy section. */ profileCursor?: string; /** * Opaque continuation cursor for the ADDITIVE profile `problems` section ONLY. * Windows collector problems independently of `cursor`/`profileCursor`, using * the SAME `limit`, so a partially-invalid profile never returns thousands of * problems per page. Drive the next batch via {@link ListPagesProfileBlock.problemCursor}. */ problemCursor?: string; } /** * Additive, non-default-profile entity-page block for `listPages`. * * Present ONLY for a non-default profile; for the built-in default it is * ABSENT (`result.profile === undefined`) so the default envelope is unchanged. * `entityPages` carries the PUBLIC `EntityPageView`s (project-relative `path`, * never an absolute `filePath`); each view's `body` is OMITTED when * `includeBody` is false (mirroring how the legacy `pages` block omits bodies). * * The entity section is BOUNDED by `limit`: it returns at most `limit` views, * deterministically sorted by `id`, with `total` reporting the full entity-page * count and `cursor` carrying the offset of the NEXT batch (absent when * exhausted). Drive the next batch via {@link ListPagesOptions.profileCursor}, * which is independent of the legacy `pages` cursor. * * @experimental Shape may change in a future release. */ interface ListPagesProfileBlock { entityPages: EntityPageView[]; /** Full count of entity pages across the whole non-default profile. */ total: number; /** * Opaque continuation cursor for the NEXT entity batch; absent when the * entity section is exhausted. Pass back via `profileCursor`. */ cursor?: string; /** * Structured collector problems, WINDOWED by `limit` independently of * `entityPages` (its own `problemCursor` offset). Present ONLY when the window * is non-empty; each `path` is project-relative (never absolute) and absent * for directory-level problems. See `problemTotal` for the full count. */ problems?: EntityProblemView[]; /** Full count of collector problems across the profile; present ONLY when non-empty. */ problemTotal?: number; /** * Opaque continuation cursor for the NEXT `problems` batch; absent when the * problem section is exhausted. Pass back via `problemCursor`. */ problemCursor?: string; } /** * Result returned by `listPages`. * * DX note: for a NON-DEFAULT profile the legacy `pages` array is scoped to * concepts/queries (typically EMPTY for an entity-only project); the entity * content lives in `profile.entityPages`. Read the entity section there, not * from `pages`. */ interface ListPagesResult { pages: Page[]; /** Opaque cursor for the next page; absent when the listing is exhausted. */ cursor?: string; /** * Non-default profile entity pages, ADDITIVELY. ABSENT (undefined) for the * built-in default so the default envelope is byte-identical; the legacy * `pages` block stays scoped to concepts/queries in both cases. For a * non-default profile this is where the entity content lives — the legacy * `pages` array is typically empty. */ profile?: ListPagesProfileBlock; /** * Read-surface health warnings. ABSENT (key omitted) for a healthy project so * the default envelope is byte-identical (parity-safe); present ONLY when the * compile journal is `pending` (`incomplete-compile`) or `unavailable` * (`journal-unavailable`), so an agent listing pages never treats partial * post-crash or tampered content as a complete, clean listing. */ warnings?: ListPagesWarning[]; } /** One read-surface health warning carried on a {@link ListPagesResult}. */ interface ListPagesWarning { code: string; message: string; } /** * @file src/relations/types.ts * @description Type surface for the typed RELATION STORE (Phase 4) — the * append-only JSONL graph at `wiki/graph/relations.jsonl`. * * A {@link RelationRef} is one typed edge between two entity pages. Its `id` * (`rel_`) is allocated ONCE at creation and is NEVER derived from or * recomputed against content — it is the stable handle a later update * references. Its `contentHash` is the canonical RFC 8785 digest over * `{type, from, to, attributes, evidence}` and IS recomputed whenever * `attributes`/`evidence` change; it is the DEDUP KEY — an append whose * `contentHash` matches a live relation is an idempotent no-op (see * `appendRelationLocked`). It is NOT yet an optimistic-concurrency precondition; * a `preconditionHash`-style check is reserved for future staged-relation edits. * Changing `type`/`from`/`to` is a delete+create (a different edge), not an * in-place update. * * On disk each line is a {@link RelationRecord}: the `RelationRef` plus a * per-record `checksum` (sha256 of the canonical record-without-checksum form). * The first line of a non-empty store is a {@link RelationStoreHeader} carrying * the store `schemaVersion`. Readers FAIL CLOSED when that version exceeds * {@link RELATION_STORE_SCHEMA_VERSION}. * * DURABILITY: a torn TRAILING line is tolerated on a clean process crash and * reported. There is NO fsync, so power-loss durability is NOT provided; an * interior tear (any malformed/bad-checksum line before the last) fails the * store closed. */ /** * A minimal citation backing a relation: the source file the claim came from * and an optional span within it (line range / char offset, free-form). The * exact span grammar is deferred; the contract here is only that it is a * stable string so it participates deterministically in the content hash. */ interface CitationRef { /** Project-relative path of the source the relation is evidenced by. */ sourcePath: string; /** Optional span within the source (free-form, e.g. `"L10-L14"`). */ sourceSpan?: string; } /** A relation id, always of the form `rel_`. */ type RelationId = `rel_${string}`; /** * One typed edge between two entity pages. * * `id` is minted once and never recomputed; `contentHash` is the canonical * digest of the content fields (the DEDUP KEY), recomputed on every * attribute/evidence update. See the file overview for the full contract. */ interface RelationRef { /** Stable handle, allocated once at creation. */ id: RelationId; /** Relation-type id, a key of `profile.relations`. */ type: string; /** The `from` endpoint entity page id (`/`). */ from: EntityId; /** The `to` endpoint entity page id. */ to: EntityId; /** Typed relation attributes (validated against the relation-type def). */ attributes: Record; /** Optional citations backing the relation. */ evidence?: CitationRef[]; /** Canonical digest over `{type, from, to, attributes, evidence}`. */ contentHash: string; } /** Raised when a relation's endpoints/attributes violate the relation-type def. */ declare class RelationEndpointError extends Error { constructor(message: string); } /** * JSON export format writer. * * Produces a structured JSON document containing all wiki pages and their * metadata. The schema is intentionally simple and human-readable so it can * be consumed directly by scripts, agents, or downstream pipelines without * additional transformation. * * Schema: * { schemaVersion, exportedAt, pageCount, projectId?, pages: ExportPage[] } * * W4 provenance lives PER PAGE (`ExportPage.modelId` / `promptVersion` plus * `contentHash` / `sourceHashes`), stamped into each page at compile time. * It is deliberately not summarized at the envelope level: a single * export-time model id would misattribute pages compiled under a different * model, which is exactly the lineage bug this avoids. * * `schemaVersion` lets downstream consumers (e.g. the rule importer) pin to a known * contract. Increment when a breaking field change lands; additive fields * do not require a bump. * * `projectId` is the optional bridge identifier. When present it pins the * on-disk export to a stable identity that downstream consumers (the * Atomic Memory adapter especially) use to derive deterministic external * IDs. Validation happens at the CLI/programmatic boundary, not here — * by the time we serialize, the value has been checked. */ /** * The PUBLIC, path-safe DTO for one typed relation in the JSON export. * * Mirrors the {@link EntityPageView} omitted-for-default discipline: it NEVER * carries an absolute path. `from`/`to` are already opaque `/` * EntityId strings (not filesystem paths). The relation's `evidence` citations * are now contract-VALIDATED on write (safe PROJECT-RELATIVE paths, allowlisted * keys), so they are INCLUDED — they are core provenance value AND make the * published `contentHash` (computed over `{type,from,to,attributes,evidence}`) * recomputable by a consumer. Only the relative citation paths are exported, never * an absolute path. * * @experimental Shape may change in a future release. */ interface RelationView { /** Stable relation handle (`rel_`). */ id: string; /** Relation-type id (a key of the profile's `relations` block). */ type: string; /** The `from` endpoint EntityId (`/`); never a filesystem path. */ from: string; /** The `to` endpoint EntityId; never a filesystem path. */ to: string; /** Typed relation attributes, as declared by the relation-type def. */ attributes: Record; /** * Validated citations backing the relation (safe project-relative paths only), * present ONLY when the relation carries evidence. Exported so a consumer can * recompute the `contentHash`; never an absolute path. */ evidence?: CitationRef[]; /** Canonical content digest over `{type, from, to, attributes, evidence}`. */ contentHash: string; } /** * Additive, non-default-profile entity block for the JSON export. * * Present ONLY for a non-default profile; ABSENT for the built-in default so * the default export is byte-identical. `entityPages` carries the PUBLIC * `EntityPageView` shape (project-relative `path`, never an absolute * `filePath`) with its `body` INCLUDED — export wants page content — NOT the * freshness/hash-decorated `ExportPage`. The legacy `pages` array stays scoped * to concepts/queries. * * @experimental Shape may change in a future release. */ interface JsonExportProfileBlock { /** * Block-level contract version (a literal `1`). Distinct from the document * `schemaVersion`: lets consumers detect future profile-block shape changes * without a default-breaking document bump. Only appears for a non-default * profile (the whole block is absent for the built-in default). */ version: 1; profileId: string; entityPages: EntityPageView[]; /** * Structured collector problems, present ONLY when non-empty. An export is a * COMPLETE snapshot, so this list is NEVER capped — every problem is retained * (each `path` project-relative, never absolute; absent for directory-level * problems). `problemTotal` mirrors `problems.length` for symmetry with the * capped status/viewer surfaces. */ problems?: EntityProblemView[]; /** Full problem count; equals `problems.length` (export is never capped). */ problemTotal?: number; /** * Path-safe views of the live relation store, present ONLY when the store * holds at least one relation; OMITTED for the built-in default and for any * relation-LESS profile so the default export envelope is byte-identical. Each * {@link RelationView} carries opaque EntityId endpoints and NO filesystem * path. A fail-closed read (corrupt / too-new store) omits this field AND adds * a `relation-store` entry to `problems` so the broken store is reported * VISIBLY, never as a silent "no relations". */ relations?: RelationView[]; } /** * One read-surface health warning carried at the top of the JSON export. * Mirrors the neutral `{ code, message }` shape the other read surfaces use. */ interface JsonExportWarning { code: string; message: string; } /** Top-level shape of the JSON export file. */ interface JsonExportDocument { /** * Contract version for downstream consumers. Start at 1; increment only on * breaking envelope changes so consumers can pin a supported range. */ schemaVersion: number; exportedAt: string; pageCount: number; /** Optional bridge identifier. See `src/export/project-id.ts` for the validation rule. */ projectId?: string; pages: ExportPage[]; /** * Non-default profile entity pages, ADDITIVELY. ABSENT (undefined) for the * built-in default so the default envelope is byte-identical. */ profile?: JsonExportProfileBlock; /** * Read-surface health warnings. ABSENT (key omitted) for a healthy project so * the default export envelope is byte-identical (parity-safe); present ONLY * when the compile journal is `pending` (`incomplete-compile`) or `unavailable` * (`journal-unavailable`), so a downstream consumer never imports partial * post-crash or tampered content as if it were a complete, clean export. */ warnings?: JsonExportWarning[]; } /** * PUBLIC options for the JSON export, exposing ONLY the legitimate caller knob. * * Deliberately omits `profile`: the profile block is computed and injected by * the export PIPELINE after it resolves the active profile, never accepted from * caller input — otherwise a default-project caller could FORGE a `profile` * block into the document. Callers (SDK `Wiki.exportJson`, the CLI export * command) accept this type, not the internal {@link BuildJsonExportOptions}. */ interface ExportJsonOptions { /** * Optional project identifier. Validated against the bridge contract * regex; throws if invalid so a malformed value never reaches disk. */ projectId?: string; } /** * Single provider-credential guard shared by every entry point that * needs an LLM call (CLI compile/query/watch, MCP tools, the upcoming * `quickstart` command). * * The guard throws on failure instead of calling `process.exit(1)`, * which lets every caller decide how to surface the failure: * * - CLI verbs catch the throw and print the message + exit 1. * - MCP tools let the throw propagate as a tool error. * - `quickstart` catches the throw and translates it into the * `compile.error = { code: "provider_unavailable", ... }` shape * documented in the next-quickstart implementation plan. * * Error messages mirror the rich CLI form (with `Set it with: export X=...` * hints) so the user always sees actionable guidance no matter which * surface fired the guard. */ /** Thrown when the active provider has no usable credentials. */ declare class ProviderUnavailableError extends Error { readonly provider: string; readonly missing: string[]; readonly code: "provider_unavailable"; constructor(provider: string, missing: string[], message: string); } /** Thrown when LLMWIKI_PROVIDER names an unsupported provider. */ declare class UnknownProviderError extends Error { readonly provider: string; readonly supported: string[]; readonly code: "unknown_provider"; constructor(provider: string, supported: string[], message: string); } /** * Commander action for `llmwiki ingest `. * * Detects the source type (URL, image, PDF, transcript, or generic file), * delegates to the appropriate ingestion module, and saves the result as a * markdown file with YAML frontmatter in the sources/ directory. * * Source type is persisted in frontmatter under the `sourceType` key for * downstream tooling and human readers. */ /** Input shape for raw-text ingestion. */ interface IngestTextInput { title: string; text: string; source?: string; } /** * Shared types for the local web viewer. * * `ViewerPage` is the in-memory page record consumed by the HTTP server's * `/api/page/:directory/:slug` endpoint. `ViewerSnapshot` is the immutable * project-wide state captured once at viewer startup and served from for * every request — v1 deliberately does not live-watch the filesystem. * * `ViewerWarning` is the only warning surface; the underlying wiki layer * (`src/wiki/collect.ts`) returns structural `parseStatus` flags, and the * viewer decorator (`src/viewer/collect.ts`) maps those into stable * `code`/`message` pairs the UI renders. */ /** * Canonical page identifier: `concepts/` or `queries/`. Bare * slugs collide between the two directories, so every viewer surface uses * the namespaced form. */ type PageId = `${PageDirectory}/${string}`; /** * The directory namespace a viewer page is ADDRESSED under — the `` of * `/api/page//` and of the page's own id. * * For a default page it is the literal {@link PageDirectory}. For a typed * entity page it is the profile's declared ENTITY TYPE id (`articles`), NOT the * type's on-disk `directory` (`wiki/articles`): the on-disk value is a * multi-segment project-relative path and can never be one route segment, while * an entity type id is slug-safe by `src/profile/identity.ts` and is guaranteed * disjoint from `concepts`/`queries` by `rejectReservedEntityTypeNames` in * `src/profile/validate.ts`. So `id === \`${pageDirectory}/${slug}\`` holds for * both kinds of page, and a typed page's id IS its {@link EntityId}. * * This is deliberately a VIEWER-owned type rather than a widening of the shared * `PageDirectory`: that union is part of the OKF export/import interchange * contract and its frozen parity goldens, and typed entity pages have no * business changing export/import semantics. */ type ViewerPageDirectory = string; /** * The identifier of any page the viewer can address: a default page's * {@link PageId} (`concepts/`), or a typed entity page's branded * {@link EntityId} (`/`). Both are string subtypes, so a * `PageId`-keyed set/map still accepts and compares them. */ type ViewerPageId = PageId | EntityId; /** * The identifier space of a graph node — the same union as * {@link ViewerPageId}, because every node is either a page or a ghost keyed in * the page id space. Aliased rather than re-spelled so the two cannot drift. * Wikilink nodes/edges keep their concrete `PageId` everywhere a default project * serializes them, so the default graph is byte-identical. */ type GraphNodeId = ViewerPageId; /** * Pure recommendation rules for `llmwiki next`. * * Classifies a {@link ProjectState} snapshot into exactly one of seven * primary states and produces a primary {@link RecommendedAction} plus * the per-state `otherActions` table from the implementation plan. * * Actions with user-supplied input (a source path, a question, a * candidate id) are templates: the display `command` carries a * `` and `executable.placeholders` lists the slots. Agents * must populate placeholders themselves; the contract never returns a * shell-ready command line. */ /** Single recommended action; `command` is for display, `executable` is for agents. */ interface RecommendedAction { command: string | null; reason: string; executable: ExecutableSpec | null; } /** Structured form of an executable command. Placeholders are slot names, not literals. */ interface ExecutableSpec { binary: "llmwiki"; args: string[]; placeholders?: string[]; } /** * Stable v1 JSON contract for `llmwiki context` and the future * `get_context_pack` MCP tool. * * Every top-level field and every documented nested key is present from * Slice 1 onward, even when later-slice features have not populated * them yet (see `localdocs/context-graph-packs-implementation-plan.md` * §JSON Contract). Unpopulated list fields are empty arrays; absent * object fields are `null`. Slices may fill data into these fields, * but must NEVER add or remove top-level keys without bumping * `version`. */ /** Closed v1 enum for why a page landed in `primary[]`. */ type PrimaryReason = "semantic-chunk" | "title-match" | "body-match" | "exact-slug" | "exact-title" | "graph-neighbor"; /** * Edge-provenance label used in `neighbors[]`. `"wikilink"` is the v1 value (a * PageId↔PageId wikilink edge); `"relation"` (CLP 4b) is an ADDITIVE value * emitted ONLY for a neighbor reached along a typed relation edge. A default * (relation-less) pack never emits `"relation"`, so its serialized output is * unchanged and `version` does not bump. */ type NeighborReason = "wikilink" | "relation"; /** * Closed v1 enum for top-level `warnings[]` codes. `relation-store-unavailable` * (CLP 4b) is an ADDITIVE value emitted ONLY for a non-default profile whose * relation store fails closed (corrupt / too-new / symlink / confinement), so an * agent SEES that typed relations are unavailable instead of getting a silently * relation-less pack. A default (relation-less) pack never emits it, so its * serialized output is unchanged and `version` does not bump. */ type ContextWarningCode = "embedding-store-missing" | "query-embedding-unavailable" | "semantic-retrieval-error" | "lint-errors" | "pending-candidates" | "source-window-unavailable" | "truncated-prompt" | "relation-store-unavailable" | "embedding-index-outdated" | "embedding-entry-stale" | "incomplete-compile" | "journal-unavailable" | "lifecycle-relation-requirement-unmet" | "lifecycle-relation-requirement-unverifiable" | "artifact-ref-unhealthy"; /** * Codes for `gaps[]`. `dangling-link`/`page-warning` are the v1 values; * `dangling-relation` (CLP 4b) is an ADDITIVE value for a typed relation whose * other endpoint has no backing page (a ghost). A default (relation-less) pack * never emits it, so its serialized output is unchanged and `version` does not * bump. */ type ContextGapCode = "dangling-link" | "page-warning" | "dangling-relation"; /** * Budget envelope. `estimatedTokens` uses a tokens ≈ chars/4 heuristic in v1. * Because `estimatedTokens` is itself serialized inside the measured JSON, the * reported value may differ from `estimatePackTokens(returnedPack)` by at most * one token of digit-count drift. */ interface ContextBudget { requestedTokens: number; estimatedTokens: number; truncated: boolean; /** * Section keys (`primary`, `neighbors`, `sourceWindows`, `chunks`) that lost * data. The ADDITIVE `"contentTiers"` key is emitted ONLY when a primary's * per-record content tiers were trimmed; a pack with no `contentTiers` anywhere * never emits it, so a default pack's trimming output is unchanged. */ trimmedSections: string[]; } /** * Cached lint summary surfaced inside `project.lint`. Matches * `LintCacheEntry` in `src/linter/cache.ts` but typed locally so the * context contract does not depend on the linter's internal shape. */ interface ContextLintSummary { warnings: number; errors: number; at: string; } /** Project block. `root` is set to `null` when `--omit-root` is supplied. */ interface ContextProject { root: string | null; pages: number; pendingCandidates: number; lint: ContextLintSummary | null; } /** One semantic chunk surfaced for a primary page. Slice 2 populates it. */ interface ContextChunk { text: string; score: number; contentHash?: string; } /** * Flattened citation. Produced by lifting `ClaimCitation.spans` into one * object per span. Paragraph-only citations omit `start` and `end`. */ interface ContextCitation { file: string; start?: number; end?: number; } /** Source line window emitted only when `--include-sources` is set in Slice 4. */ interface ContextSourceWindow { file: string; start: number; end: number; text: string; } /** Page-local warning surfaced from the viewer collector. */ interface ContextPageWarning { code: string; message: string; } /** * One revealed content-depth tier of a primary record. `tier` is the declared * frontmatter field name (or the reserved `body` token) it projects; `content` is * that field's stringified value (or the page body). See {@link ContextPrimary.contentTiers}. */ interface ContextTier { tier: string; content: string; } /** One primary page entry. `reasons` is sorted alphabetically for stable output. */ interface ContextPrimary { /** * A default page's `PageId`, or a typed entity page's `EntityId`. Typed pages * have been rankable primaries since the pool started carrying them; the union * only makes that honest at the type level. A DEFAULT pack still emits nothing * but `PageId`s, so its wire shape is unchanged. */ id: ViewerPageId; title: string; /** `concepts`/`queries`, or a typed page's entity type. See {@link ViewerPageDirectory}. */ pageDirectory: ViewerPageDirectory; score: number; reasons: PrimaryReason[]; summary: string; chunks: ContextChunk[]; citations: ContextCitation[]; sourceWindows: ContextSourceWindow[]; warnings: ContextPageWarning[]; /** Computed source-freshness of this page (advisory snapshot, not a guarantee). */ freshnessStatus: FreshnessStatus; /** Disputed by another page (`contradictedBy` non-empty). */ contradicted: boolean; /** Explicitly archived (`archived: true` frontmatter). */ archived: boolean; /** * Ordered shallowest-first per-record content tiers, projected when this * record's entity type declares `contentTiers` (CLP 6.2). ABSENT unless that * type declares the key (⇒ default packs are byte-identical and `version` does * NOT bump); the projection only augments an already-ranked record, never * reorders `primary[]`. */ contentTiers?: ContextTier[]; } /** * One graph neighbor edge. `distance` is 1 for direct, 2 for second-hop. * Endpoints are in the {@link GraphNodeId} space so a typed entity node reached * along a relation edge (CLP 4b) is a valid neighbor; for a wikilink-only * (default) pack every endpoint is still a `PageId`, so the wire shape is * unchanged. */ interface ContextNeighbor { from: GraphNodeId; to: GraphNodeId; direction: "outgoing" | "incoming"; distance: number; score: number; reason: NeighborReason; /** * The profile relation type a relation-derived neighbor was reached along (CLP * 4b). ABSENT when `reason` is `"wikilink"`, so a default pack's neighbor shape * is byte-identical; present only when `reason` is `"relation"`. */ relationType?: string; } /** Top-level context-pack state warning. */ interface ContextWarning { code: ContextWarningCode; message: string; } /** * Missing-knowledge gap. `pageId` is required; every gap code * (`dangling-link`, `page-warning`, `dangling-relation`) is tied to a specific * source. It is keyed in the {@link GraphNodeId} space so a `dangling-relation` * gap (CLP 4b) can name a typed entity page (an `EntityId`) as its source; for * a default pack every gap source is still a `PageId`, so the wire shape is * unchanged. A future project-wide gap would either bump `version` or introduce * a new sibling field rather than retrofitting nullability onto this one. */ interface ContextGap { code: ContextGapCode; message: string; pageId: GraphNodeId; } /** Top-level v1 envelope. */ interface ContextPack { version: 1; prompt: string; budget: ContextBudget; project: ContextProject; primary: ContextPrimary[]; neighbors: ContextNeighbor[]; warnings: ContextWarning[]; gaps: ContextGap[]; suggestedActions: RecommendedAction[]; } /** * Type definitions for the llmwiki eval harness. * * Four metric families: * - HealthResult: aggregated lint score (0–100) * - CitationCoverageResult: prose paragraph citation rate + precision * - CitationSupportResult: LLM-judged citation support quality (full suite only) * - StatsResult: corpus size snapshot appended to history.jsonl * * EvalReport bundles all four plus regression deltas and CI threshold violations. */ interface HealthRuleResult { rule: string; count: number; severity: "error" | "warning" | "info"; deduction: number; } interface HealthResult { score: number; maxScore: 100; rules: HealthRuleResult[]; /** * Number of pages currently awaiting review in .llmwiki/candidates/. * Informational only — does not affect the health score. */ pendingReviews: number; } interface CitationPageResult { slug: string; proseParagraphs: number; citedParagraphs: number; } interface CitationCoverageResult { totalProseParagraphs: number; citedParagraphs: number; coveragePercent: number; totalCitations: number; validCitations: number; precisionPercent: number; perPage: CitationPageResult[]; } /** Per-source citation detail emitted by source-utilization eval. */ interface SourceUtilizationEntry { sourceFile: string; citingPageCount: number; citingPages: string[]; } interface SourceUtilizationResult { totalSources: number; citedSources: number; uncitedSources: number; /** 0.0-1.0, or null when totalSources is 0 (not measured). */ utilizationRate: number | null; /** Non-fatal issues encountered during evaluation (e.g. unreadable files). */ warnings: string[]; /** Sorted by citingPageCount descending. */ perSource: SourceUtilizationEntry[]; } /** Citation depth metrics — how precise are the wiki's citations. */ interface CitationDepthResult { totalCitations: number; preciseCitations: number; vagueCitations: number; /** 0.0-1.0 fraction of citations that include a line range. */ claimLevelRate: number; /** Average number of citation markers per prose paragraph. */ avgCitationsPerParagraph: number; } interface PageHealthEntry { slug: string; score: number; tier: "healthy" | "adequate" | "needs_work" | "broken"; topIssues: string[]; } interface PageHealthDistributionResult { distribution: { healthy: number; adequate: number; needs_work: number; broken: number; }; perPage: PageHealthEntry[]; worstPages: PageHealthEntry[]; } interface HubPage { id: string; indegree: number; outdegree: number; totalDegree: number; } interface DanglingTarget { id: string; title: string; referenceCount: number; } interface GraphHealthResult { pageCount: number; unreferencedCount: number; unreferencedPages: string[]; componentCount: number; avgIndegree: number; hubPages: HubPage[]; danglingCount: number; topDangling: DanglingTarget[]; } interface CitationJudgement { /** First 16 hex chars of SHA-256(claimText + spanText) — stable cache key. */ claimHash: string; pageSlug: string; citedFile: string; lineStart: number; lineEnd: number; claimText: string; spanText: string; score: 0 | 1 | 2; reason: string; model: string; timestamp: string; } interface CitationSupportResult { sampledCount: number; /** Ordered list of claimHash values evaluated in this run. Persisted so subsequent runs can retain the same sample as the corpus grows. */ sampledHashes: string[]; totalCitations: number; meanScore: number; fullySupported: number; partiallySupported: number; unsupported: number; /** Number of judge calls that threw (credentials failure, network error, parse error). */ judgeErrors: number; judgements: CitationJudgement[]; } interface StatsResult { timestamp: string; sourceCount: number; pageCount: number; totalWikiChars: number; embeddingCount: number; chunkEmbeddingCount: number; avgPageLengthChars: number; /** * Whether a usable v3 embedding index was found. `false` is a DISTINCT * "unavailable" signal — a missing, corrupt, or pre-v3 (outdated) store — so a * degraded index is never silently reported as `embeddingCount: 0`. Omitted * (key absent) when the index is available, keeping healthy output unchanged. */ embeddingsAvailable?: boolean; } interface EvalDelta { healthScore?: number; citationCoveragePercent?: number; citationPrecisionPercent?: number; citationSupportMean?: number; } interface EvalReport { suite: "fast" | "full"; timestamp: string; health: HealthResult; citationCoverage: CitationCoverageResult; sourceUtilization: SourceUtilizationResult; citationDepth: CitationDepthResult; pageHealthDistribution?: PageHealthDistributionResult; graphHealth?: GraphHealthResult; citationSupport?: CitationSupportResult; stats: StatsResult; delta?: EvalDelta; thresholdViolations: string[]; } /** * Read-only project-status collector shared by the MCP `wiki_status` tool and the in-process SDK. * * Derives stale/orphaned page classification from the freshness module so * agents get source-level accuracy rather than frontmatter-only orphans. * Uses `readStateClassified` throughout — never `readState` — so corrupt * state.json never produces a `.bak` side-effect. * * `pendingChanges` is derived directly from the freshness snapshot (which * already has per-source currentHash/recordedHash/exists) plus a cheap * selected-file listing — no second hash pass. `detectChanges` is NOT called. */ /** Shape returned by `collectStatus` and surfaced by the `wiki_status` tool. */ interface WikiStatus { pages: { concepts: number; queries: number; total: number; }; sources: number; lastCompiledAt: string | null; /** * Concept slugs whose source changed or partially disappeared since compile. * Capped at MAX_STATUS_LIST (sorted ascending); see staleCount for the true total. */ stalePages: string[]; /** True total of stale pages (may exceed stalePages.length when capped). */ staleCount: number; /** * Concept slugs whose every owning source was deleted, or frontmatter-flagged orphaned. * Capped at MAX_STATUS_LIST (sorted ascending); see orphanedCount for the true total. */ orphanedPages: string[]; /** True total of orphaned pages (may exceed orphanedPages.length when capped). */ orphanedCount: number; /** * Readability of .llmwiki/state.json — surfaced so corrupt OR too-new state * (written by a newer llmwiki) is never silently reported as healthy. */ stateStatus: StateStatus; /** Number of compile candidates awaiting human review. */ pendingCandidates: number; /** * Source files with changes since last compile (new/changed/deleted). * Capped at MAX_STATUS_LIST (sorted by file); see pendingChangesCount for the true total. */ pendingChanges: Array<{ file: string; status: string; }>; /** True total of pending changes (may exceed pendingChanges.length when capped). */ pendingChangesCount: number; /** * Read-surface health warnings carried alongside the status payload. ABSENT * (key omitted) for a healthy project, so a clean status envelope is * byte-identical (parity-safe); present ONLY when the journal is `pending` * (`incomplete-compile`) or `unavailable` (`journal-unavailable`), or when an * embeddings refresh is still pending (`embeddings-refresh-pending`), so partial * post-crash / tampered state OR a skipped embeddings update is never reported as * silently healthy. Mirrors the journal-warning surfacing on viewer/export/context. */ warnings?: ReadSurfaceWarning[]; /** * Active non-default profile summary. ABSENT (undefined) for the default * profile so default envelopes are unchanged. When present, `entityCounts` * is the per-entity-type page count; the legacy `pages` block above stays * scoped to the literal wiki/concepts + wiki/queries dirs only. */ profile?: { profileId: string; digest: string; entityCounts: Record; /** * Structured problems from the non-default read path (invalid directories, * non-slug-safe filenames, slug mismatches, field-contract violations), * CAPPED at PROFILE_PROBLEM_CAP; each `path` is project-relative (never * absolute) and absent for directory-level problems. Present ONLY when * non-empty, so a non-default project with a bad directory or page is never * reported as silently healthy; see `problemTotal` for the full count. */ problems?: EntityProblemView[]; /** Full problem count (may exceed `problems.length` when capped). */ problemTotal?: number; /** * Per-entity-type tally of the CURRENT lifecycle-field value across that * type's enrolled pages (e.g. `{ ideas: { proposed: 1, testing: 2 } }`), * computed ONLY for entity types declaring a `lifecycle`, from the bounded * frontmatter-only scan. This is what makes a lifecycle TRANSITION visible * to `status`/`wiki_status`: a field-flip changes these counts. ABSENT when * no entity type declares a lifecycle (or none is enrolled), so default and * lifecycle-less envelopes stay byte-identical. */ lifecycleStates?: Record>; }; } /** * @file src/sources/source-record.ts * @description The pure (no-I/O) source-record vocabulary shared by the source * store: the {@link SourceRecord} shape and its pagination option/result types, * the relative-path id-safety guard, and the frontmatter→record projection. * * Factored out of `./store.ts` so the I/O entry points (`listSources`, * `getSource`, `deleteSource`) stay a thin filesystem layer over these pure * helpers — and so the record/guard logic is unit-testable without touching disk. */ /** A single source file under `sources/`, with frontmatter metadata. */ interface SourceRecord { id: string; title: string; source: string; sourceType: string; ingestedAt?: string; body?: string; } /** Options for paginating `listSources` and opting into source bodies. */ interface ListSourcesOptions { cursor?: string; limit?: number; includeBody?: boolean; } /** Result returned by `listSources`. */ interface ListSourcesResult { sources: SourceRecord[]; cursor?: string; } interface OkfExportReport { outDir: string; writtenPaths: string[]; warnings: string[]; } /** The per-doc outcome of the typed import leg, surfaced in the import report. */ interface TypedImportOutcome { /** Bundle-relative source path. */ okfPath: string; /** The candidate/page slug the outcome applies to. */ slug: string; /** Declared entity type, when the doc resolved one. */ entityType?: string; /** * `staged-typed`: staged as a typed review candidate (untrusted, or trusted with * a promotion refusal — see `reason`). `promoted-typed`: staged then promoted * live through the planner. `mismatch-fallback`: routed to untyped staging * (unknown type / contract violation). `skipped`: a collision skip. */ outcome: "staged-typed" | "promoted-typed" | "mismatch-fallback" | "skipped"; /** Detail for a fallback (mismatch reason), a skip (collision reason), or a promotion refusal. */ reason?: string; } /** The bundle profile identity + producer sub-block (D-7.6.2). */ interface BundleProfileBlock { profileId: string; /** Omitted when the profile declares no version. */ profileVersion?: string; profileSchemaVersion: number; profileContentHash: string; entityTypes: string[]; relationTypes: string[]; artifactTypes: string[]; producer: { name: string; version: string; }; } /** One live relation, flattened for the bundle graph (evidence omitted in v0). */ interface BundleRelationEntry { id: string; type: string; from: string; to: string; /** Omitted when the relation carries no attributes. */ attributes?: Record; contentHash: string; } /** How the bundle's declared profile differs from the ACTIVE LOCAL profile. */ interface BundleProfileMismatch { /** True when the local project runs the built-in default (no active profile). */ noActiveProfile?: boolean; differingProfileId?: { bundle: string; local: string; }; differingProfileContentHash?: { bundle: string; local: string; }; /** Bundle entity/relation types not declared under the active local profile. */ entityTypesNotDeclaredLocally: string[]; relationTypesNotDeclaredLocally: string[]; /** Present when records are interpreted through a different (or the default) profile. */ note?: string; } /** The parsed foreign profile identity plus its mismatch vs the local profile. */ interface BundleProfileReport { profile: BundleProfileBlock; mismatch: BundleProfileMismatch; } /** Parsed relations: a count plus the entries carried through for later application (Task 5). */ interface BundleRelationsReport { count: number; entries: BundleRelationEntry[]; } /** Parsed workflow-run summaries — surfaced only; INERT (D-7.6.7). */ interface BundleWorkflowsReport { count: number; runIds: string[]; } /** The additive import-report sections a parsed bundle block contributes. */ interface BundleReportSections { bundleProfile?: BundleProfileReport; bundleRelations?: BundleRelationsReport; bundleWorkflows?: BundleWorkflowsReport; } /** * @file The bundle-RELATION leg of OKF import (CLP 7.6 Task 5, D-7.6.6). * * Task 3 parses the bundle-level `x-llmwiki.relations` list into untrusted, * leaf-type-checked {@link BundleRelationEntry} records. This module APPLIES them * to the local relation store — TRUSTED MODE ONLY. v0 has no staged-relation * review path, so an UNTRUSTED import must never write a relation (every entry is * reported `skipped-untrusted`); only `--trusted` promotes them through the * validated write seam. * * Each trusted entry is built into an {@link AppendRelationInput} carrying ONLY * `type`/`from`/`to`/`attributes`. The foreign `id` and `contentHash` are NEVER * trusted — the store mints its own id and recomputes its own content hash (they * are reporting-only on the parsed entry); no `evidence` is carried in v0. The * entry endpoints stay verbatim strings: the store's OWN validation (canonical * endpoint entity-type scope + slug-safe {@link EntityId} grammar + attribute * contract) is the trust boundary, so a bad endpoint is REFUSED, never * pre-normalized into acceptance. * * Outcomes per entry: a fresh append is `imported`; a content-hash dedup hit * (the store returns the EXISTING record — detected because its id was already * live) is `deduplicated`; a {@link RelationEndpointError} (unknown type, * endpoint-scope refusal, or attribute-contract failure) is `skipped-invalid` * with the reason. A store-full / corrupt error FAILS CLOSED: the remaining * entries are aborted and reported `skipped-store-error`, but the page import * (already landed) is NOT failed — the report carries the truncation honestly. * * The caller MUST already hold the project lock: this routes through the * lock-free {@link appendRelationLocked} (a self-locking append would deadlock * under the held import lock, mirroring the typed-doc leg). */ /** How a single bundle relation resolved against the local relation store. */ type RelationImportResult = "imported" | "deduplicated" | "skipped-invalid" | "skipped-untrusted" | "skipped-no-profile" | "skipped-dry-run" | "skipped-store-error"; /** One entry's import outcome, surfaced in the import report's `relationOutcomes`. */ interface RelationImportOutcome { type: string; from: string; to: string; outcome: RelationImportResult; /** Detail for a `skipped-invalid` refusal or a `skipped-store-error` abort. */ reason?: string; } interface OkfImportSkip { slug: string; okfPath: string; reason: "live-page" | "pending-candidate" | "duplicate-in-bundle" | "invalid-page"; } interface OkfImportedPage { slug: string; okfPath: string; targetDirectory: "concepts" | "queries"; } interface OkfImportReport extends BundleReportSections { mode: "staged" | "written" | "dry-run"; pages: OkfImportedPage[]; skipped: OkfImportSkip[]; warnings: string[]; /** * Per-doc outcomes of the TYPED profile-entity leg (CLP 7.6): typed staging / * planner promotion / mismatch-fallback / collision skip. OMITTED (key absent) * when the bundle contributes no typed docs, so a default-profile import's * report stays byte-identical (D-7.6.10 parity). */ typed?: TypedImportOutcome[]; /** * Per-entry outcomes of the bundle-RELATION leg (CLP 7.6, D-7.6.6): trusted * apply through the validated store (`imported`/`deduplicated`/`skipped-invalid`), * or an inert `skipped-untrusted`/`skipped-no-profile`/`skipped-dry-run`. OMITTED * when the bundle carries no relations, so default-profile parity holds. */ relationOutcomes?: RelationImportOutcome[]; nextAction?: string; } /** * @file src/relations/store.ts * @description The WRITE half of the append-only relation store. Canonicalizes * symmetric endpoints, validates the CANONICAL relation against its profile * relation-type def, mints a stable `rel_` id, and APPENDS the record * (record + checksum) under the project lock as a single writer. A new store * gets a header line first. The read half lives in `store-read.ts`. * * DEDUP (FIX #6): under the lock, an append whose `contentHash` matches a LIVE * relation short-circuits to that existing ref WITHOUT appending — so a * symmetric (a→b) then (b→a) (which canonicalize identically) collapse to ONE * record. Creates are idempotent on content. * * DURABILITY: appends use O_APPEND under a bounded-blocking project lock so a * single writer extends the file. There is NO fsync: a clean process crash can * leave a torn TRAILING line (the reader tolerates and reports it), but * power-loss durability is NOT provided, and an interior tear fails the store * closed. Appends FAIL CLOSED ({@link RelationStoreFullError}) at the read cap; * {@link compactRelations} is the escape valve. CONFINEMENT: the graph dir is * resolved through {@link resolveGraphDir}, which fails closed if `wiki/graph` is * a symlink escaping root. * * UPDATE: because the store is append-only, {@link updateRelation} appends a * NEW record carrying the SAME `id` with a recomputed `contentHash`, under ONE * lock with its base re-read (FIX #5, no lost update). The reader returns the * latest record per id, so the prior record is superseded while staying on disk * for audit. Changing `type`/`from`/`to` is a delete+create (a different edge) * and is NOT an update — callers pass only attributes/evidence. * * TRUST BOUNDARY: the per-record `contentHash` is an INTEGRITY checksum (it * detects an accidentally-corrupted or torn record, failing the read closed) — it * is NOT an AUTHENTICITY chain. Unlike the event store, this store carries no * hash-CHAIN linking each record to the prior, so it cannot detect a well-formed * record that was FORGED or a live record that was SILENTLY DELETED by something * writing the file directly. Every TOOLED write path is guarded (profile-lock * single-writer + graph-dir confinement), so no CLI/SDK/MCP caller can forge an * edge; the residual exposure is an actor with DIRECT filesystem write access to * `wiki/graph`, who could plant a relation that satisfies a gated lifecycle * precondition (G1) with no tamper-evidence. Deployments that must resist that * threat MUST protect the graph directory at the OS/filesystem layer (restrictive * ownership/permissions, or an immutable/append-only mount); a future authenticity * hash-chain over the relation store would close it in-band. This is a deliberate * v0 boundary, scoped and documented rather than silently assumed. */ /** The caller-supplied content of a new relation (id + hash are derived). */ interface AppendRelationInput { type: string; from: EntityId; to: EntityId; attributes?: Record; evidence?: CitationRef[]; } /** * @file src/trust/planner.ts * @description The WRITE PLANNER — the single seam through which every proposed * mutation passes (CLP Invariant 4). The planner does NOT touch disk: it runs * the mandatory trust checks, composes the {@link TrustDecision}, and emits a * declarative plan of {@link PlannedMutation}s for the executor to apply. * * Vocabulary is declared in full for the whole CLP mutation surface * (page / relation / artifact / lifecycle-transition / workflow-gate / * workflow-state). This module's {@link planPageMutation} plans PAGE mutations; * `relation`, `lifecycle-transition`, and `artifact` are real executor kinds * (constructed as intents and dispatched through the seam — artifact planning * lives in `src/artifacts/plan.ts`, dispatched by `applyArtifactLocked`). The * executor still rejects `workflow-*` as `not-implemented`. * * Decision→plan mapping (per the Trust Guard spec): * - `allow` / `allow-with-warning` ⇒ exactly ONE live-write mutation (a `create` * for a free target, an `update` for an existing one). * - `deny` / `stage-for-review` / `quarantine` ⇒ NO live-write mutation * (`planned: []`); the returned decision carries the routing, so nothing is * ever applied behind a block. * * The target path is derived lexically as `wiki//.md` so the * mandatory path-confinement check can run BEFORE any slug-safe id is minted — * an escaping slug is rejected by the decision, never by an exception. */ /** Every CLP store a mutation can target. The executor handles `page`, `relation`, * `lifecycle-transition`, and `artifact` kinds; `workflow-*` is not-implemented. */ type MutationKind = "page" | "relation" | "artifact" | "lifecycle-transition" | "workflow-gate" | "workflow-state"; /** The shape of a mutation against its store. */ type MutationOperation = "create" | "update" | "delete" | "transition"; /** Reference to a profile entity page: type + slug, plus its branded id. */ interface EntityRef { entityType: string; slug: string; id: EntityId; } /** * Reference to a DEFAULT wiki page: its directory and raw slug stem, with NO * typed identity. Default pages keep their Unicode `slugify` slugs (e.g. * `café-society`) and, per the Phase-1 invariant, NEVER become EntityIds — so * they carry a raw stem here rather than a branded {@link EntityId}. */ interface RawPageRef { directory: string; slug: string; } /** * The store-specific target a mutation acts on. PROFILE entity pages use * {@link EntityRef} (typed identity); DEFAULT pages use {@link RawPageRef} (raw * stem, no typed identity). */ type MutationTarget = EntityRef | RawPageRef; /** Where a proposed mutation came from and how it was vetted. */ interface MutationProvenance { /** The origin surface/actor that proposed the mutation (e.g. `"agent"`). */ origin: string; /** The composed decision the planner reached for this mutation. */ decision: TrustDecision; /** Whether the proposing surface routes risky writes for human review. */ reviewRouted: boolean; } /** * One planned, approved PAGE mutation. `target` is a union over page stores * ({@link EntityRef} | {@link RawPageRef}) and the mutation carries the `body` * bytes to write. `proposedHash` / `preconditionHash` are reserved for * optimistic-concurrency enforcement in later tasks. */ interface PagePlannedMutation { kind: "page"; operation: MutationOperation; target: MutationTarget; /** The body bytes to write for a page mutation. */ body: string; /** Hash of the proposed content (reserved; not enforced in Task 5). */ proposedHash?: string; /** Expected hash of the current target (reserved; not enforced in Task 5). */ preconditionHash?: string; provenance: MutationProvenance; } /** * One planned `relation` mutation — INTENT ONLY (no page body). The persisted * {@link RelationRef} flows back through the executor's apply RESULT, not the * plan. Carries the validated {@link AppendRelationInput} the relation store * appends. */ interface RelationPlannedMutation { kind: "relation"; operation: "create"; input: AppendRelationInput; } /** * One planned `lifecycle-transition` mutation — INTENT ONLY (no page body). It * names the entity (`entityType`/`slug`) and the `toState` to move it to, with * optional `evidence` recorded on the transition. */ interface LifecycleTransitionPlannedMutation { kind: "lifecycle-transition"; entityType: string; slug: string; toState: string; evidence?: Record; } /** Provenance origins an artifact write may carry: the direct #9A CLI/SDK surfaces * plus the workflow-produced surfaces (a plain workflow submit vs an MCP-triggered * workflow action, attributed distinctly and never spoofable to cli/sdk — F2). */ type ArtifactOrigin = "cli" | "sdk" | "workflow" | "workflow-mcp"; /** Intent to write a typed artifact's bytes. The executor re-validates under lock. */ interface ArtifactPlannedMutation { kind: "artifact"; artifactType: string; slug: string; body: string; /** Provenance recorded in the audit event; set by the calling surface (F2). */ origin: ArtifactOrigin; } /** * One planned, approved mutation — a discriminated union over stores. A `page` * mutation carries a body; `relation`/`lifecycle-transition` mutations carry * intent only (no body), routed through the same planner→executor seam. */ type PlannedMutation = PagePlannedMutation | RelationPlannedMutation | LifecycleTransitionPlannedMutation | ArtifactPlannedMutation; /** * @file src/trust/staged-change.ts * @description The typed STAGED-CHANGE model and the fail-closed staged-write * volume bound — the CLP Phase-2 deferred refinement of the candidate cap. * * When the Write Planner reaches a non-live-write {@link TrustDecision} * (`stage-for-review` / `quarantine` / `deny`), the proposed mutation is not * applied; it is captured as a {@link StagedChange} — a declarative, * serializable record of WHAT was proposed, WHY it was held, and the exact * {@link PlannedMutation}s that would run on approval. This module owns only * those types plus the volume guard; it performs NO I/O and holds no relation/ * artifact logic. * * Volume bound. Staging is an unbounded-by-default surface: an untrusted actor * can otherwise flood the review queue. {@link assertStagedWriteBudget} * generalizes the existing fail-closed candidate cap — `src/import/run.ts` * throws `QueueFullError` when `pending + valid > maxNewCandidates` for * untrusted imports, and `src/mcp/okf-tools.ts` pins * `MAX_MCP_PENDING_CANDIDATES = 200` — into a two-level (per-call + per-session) * ceiling that throws a typed {@link StagedWriteOverflowError} naming the * breached cap. It NEVER silently clamps: an overflow is an error the caller * must handle, not a truncation it can miss. * * Cap defaults. {@link DEFAULT_STAGED_WRITE_PER_SESSION} is `200`, anchored * directly to the existing pending-candidate ceiling so the two surfaces agree. * {@link DEFAULT_STAGED_WRITE_PER_CALL} is `50`: a single planning call should * not be able to consume the whole session budget in one shot, but the limit is * generous enough for realistic batch proposals. Per-session ≥ per-call always. * * `StagedRelationRef` / `StagedArtifactRef` are Phase-4 STUBS declared here purely * so the {@link StagedChange.target} union is total across all {@link CandidateKind}s; * relation/artifact staging logic lands later and may replace these shapes. They * are deliberately distinct from the canonical relation `RelationRef` * (`src/relations/types.ts`), which is the real persisted-edge type. */ /** The store a staged change targets. Phase 2 stages `page` only. */ type CandidateKind = "page" | "relation" | "artifact" | "lifecycle-transition" | "workflow-gate"; /** * Why a change was held for review. OPEN union that imports and extends the * canonical review-policy {@link PolicyHeldReasonCode} (so the policy codes — * `low-confidence`, `contradicted`, `schema-violating`, `provenance-violating`, * `manual-review-requested`, `imported-okf`, `all`) stay a strict subset, plus * the trust-routing codes `trust-blocked` / `human-gate`, and any future code. */ type HeldReasonCode = PolicyHeldReasonCode | "trust-blocked" | "human-gate" | (string & {}); /** Phase-4 STUB: a staged relation target. Distinct from the canonical `RelationRef`. */ interface StagedRelationRef { fromId: string; toId: string; relationType: string; } /** Phase-4 STUB: a staged artifact target. Replaced when artifacts land. */ interface StagedArtifactRef { artifactId: string; artifactType: string; } /** Target of a staged workflow-gate change. */ interface WorkflowRunRef { workflowRunId: string; } /** * One held, not-yet-applied mutation captured for review. `planned` is the exact * set of live writes that would run on approval; `trustDecision` is the routing * the planner reached; `preconditionHash` (when present) pins optimistic * concurrency for replay. */ interface StagedChange { id: string; kind: CandidateKind; target: EntityRef | StagedRelationRef | StagedArtifactRef | WorkflowRunRef; operation: MutationOperation; planned: PlannedMutation[]; heldReasons: HeldReasonCode[]; trustDecision: TrustDecision; preconditionHash?: string; createdAt: string; } /** * @file src/profile/lifecycle.ts * @description The single, PURE per-page lifecycle-TRANSITION validator — the * RUNTIME counterpart to the load-time {@link validateLifecycle} in `validate.ts`. * * Where `validateLifecycle` proves a {@link LifecycleDef} is itself well-formed * when a profile loads, this module enforces that a CONCRETE page write obeys * that FSM: a page's lifecycle-field value must be a declared state, a change of * that value from its previous on-disk value must follow a legal out-edge, and * any evidence a target state requires must be present in the frontmatter. * * Given a page's `prev` state (the value on disk, or `undefined` when the page is * being created), its parsed `frontmatter`, and the resolved {@link LifecycleDef}, * {@link validateLifecycleTransition} returns a list of PATH-FREE problem * messages. It is pure (no I/O), never throws, and carries no path/entity-type * context — callers attach that when they wrap each message into their own * error/problem shape (mirroring `field-contract.ts`). */ /** * Thrown by the typed WRITE gates (staging / promotion) when a candidate body's * lifecycle-field value performs an illegal transition, names an undeclared * state, or omits required transition evidence. Fails CLOSED so a lifecycle- * violating typed page is never staged or promoted (on the READ surfaces the * same violations are non-fatal lint findings, not throws). Carries the * structured list of PATH-FREE problem messages so callers can surface exactly * what failed. */ declare class LifecycleTransitionError extends Error { /** The PATH-FREE lifecycle problem messages that caused the refusal. */ readonly problems: string[]; constructor(entityType: string, slug: string, problems: string[]); } /** * @file src/trust/staging.ts * @description SDK-level NON-DEFAULT entity page staging loop (CLP Phase-3 PR6). * * Proves the full staging round-trip through the Write Planner WITHOUT any LLM: * * 1. {@link stageEntityPage} plans a non-default entity write via the TYPED * {@link planPageMutation}, enforces the fail-closed staged-write volume * bound ({@link assertStagedWriteBudget}) BEFORE persisting anything, builds a * {@link StagedChange} wrapping the planned mutation, and persists it as a * typed review candidate (`targetEntityType` + `trustDecision`) — the * candidate store IS the staging mechanism per the Phase-2 spec. * 2. {@link promoteStagedEntityPage} re-reads that candidate, RE-PLANS the write * (so the mandatory floor + path-confinement re-run at promotion time), and * applies it through the executor so the page lands at * `wiki//.md`, then clears the candidate. * * The DEFAULT compile/review/import path is untouched: this is an additive, * programmatic slice. The staged body is caller-provided (no generation). * * READ-INTEGRATION STATUS (honest scope). Typed entity pages are surfaced in * `status`, the JSON export, the wiki INDEX, the viewer graph, agent context packs * (lexical ranking + relation-edge expansion), and semantic search (under their * qualified EntityId). Per-entity-type viewer UI beyond basic node/edge distinction * and cross-type wikilink resolution remain deferred. */ /** * @experimental * Thrown when the SDK staging entry point is called on a project that has NO * non-default profile. Staging targets a typed `wiki//.md` * path, which only a non-default profile declares — so a default project cannot * stage. Fails CLOSED before any planning or I/O. */ declare class StagingRequiresProfileError extends Error { constructor(); } /** * @experimental * SDK staging input: the entity page to stage WITHOUT the `ProfilePack` — the SDK * loads the active non-default profile itself. `existingStagedCount` (defaults to * 0) is only a FLOOR hint; the per-session cap is enforced against the real * on-disk candidate count by {@link stageEntityPage}, so a caller cannot bypass * it by passing 0. */ interface SdkStageEntityPageInput { /** Profile entity type (the wiki subdirectory), e.g. `"papers"`. */ entityType: string; /** Page slug (the filename stem); may be invalid — the planner decides. */ slug: string; /** Full markdown body (frontmatter + prose) to stage verbatim. */ body: string; /** Staged writes already held this session (for the volume bound). Defaults to 0. */ existingStagedCount?: number; } /** * @file src/workflows/actions.ts * @description The read-only workflow-ACTION discovery operations (`list`/`show`). * * Surfaces the `workflowActions` declared in the active profile. `listActions` * projects each declared action to a lightweight summary; `showAction` resolves * one declared action and computes its EFFECTIVE permission per surface — the * FIRST consumer of the authority model: * * effectivePermissions[surface] = * effectivePermission(def.permissions[surface], loadLocalGrant(root, surface), surface) * = min(profile request, local grant, surface hard cap) * * so a portable profile can never RAISE authority (an `mcp` request for * `trusted-write` clamps to the `staged-write` surface cap; a local config can * only tighten further). Both are pure reads: they load the profile (+ the * confined local config) and project it, creating nothing and taking no lock. * A default-profile project (which declares no actions) yields `[]`. An * undeclared id is resolved by an OWN-property check (never the prototype chain) * and fails closed with {@link UnknownActionError}. */ /** A declared workflow action surfaced to the `list` operation. */ interface ActionSummary { /** The slug-safe dotted id (`.`) of the declared action. */ actionId: string; /** The action's human-readable label. */ label: string; /** The declared workflow the action operates on. */ workflow: string; /** The workflow operation the action resolves to (`start`/`advance`/…). */ operation: string; } /** A declared workflow action with its full detail + effective per-surface permission. */ interface ActionDetail extends ActionSummary { /** The action's declarative input schema, when declared. */ inputSchema?: Record; /** A `human:`/`agent:` gate this action satisfies, when declared. */ gate?: string; /** A `trust:` gate this action's write must pass, when declared. */ trustGate?: string; /** The EFFECTIVE permission per surface = min(request, local grant, surface cap). */ effectivePermissions: Record; } /** * @file src/workflows/list.ts * @description The read-only `list` operation over a project's declared workflows. * * Surfaces the workflows declared in the active profile's `workflows` block as * lightweight summaries (id + stage ids in declared order). This is a pure read: * it loads the profile and projects it, creating nothing and taking no lock. A * default-profile project (which declares no workflows) yields an empty list. */ /** A declared workflow surfaced to the list operation. */ interface WorkflowSummary { /** The slug-safe id of the declared workflow. */ workflowId: string; /** The workflow's stage ids, in declared order. */ stageIds: string[]; } /** * @file src/workflows/show.ts * @description The read-only `show` operation over ONE declared workflow. * * Where `list` surfaces only each workflow's id + stage ids, `show` surfaces the * full per-stage contract an agent/operator needs to drive the workflow: each * stage's `reads`/`writes`/`gate`/`previousIds`, the workflow's `projectionFile`, * and the declared workflow ACTIONS that target it. A pure read: it loads the * profile and projects it, creating nothing and taking no lock. An unknown * workflow id fails closed with {@link UnknownWorkflowError} (never a silent * empty), mirroring `start`'s fail-closed lookup. */ /** One stage's declared contract, surfaced to the `show` operation. */ interface WorkflowStageDetail { /** The slug-safe stage id. */ id: string; /** Declared entity-type ids this stage reads. */ reads: string[]; /** Declared entity-type ids this stage writes. */ writes: string[]; /** The stage's `:` gate, when declared. */ gate?: string; /** Prior stage ids this stage was renamed FROM, when declared. */ previousIds?: string[]; } /** A declared workflow's full detail: its stages, projection target, and actions. */ interface WorkflowDetail { /** The slug-safe id of the declared workflow. */ workflowId: string; /** Each stage's declared contract (reads/writes/gate/previousIds), in order. */ stages: WorkflowStageDetail[]; /** The workflow's declared markdown projection target, when one exists. */ projectionFile?: string; /** The ids of declared workflow actions that target this workflow. */ actions: string[]; } /** * @file src/workflows/types.ts * @description The durable, core-owned workflow run record type. * * A {@link WorkflowRun} is the SOURCE OF TRUTH for one workflow run, persisted * as a single JSON file under `.llmwiki/workflows/runs/.json` (a private * dir, never emitted into `wiki/` output). This module defines ONLY the record * shape and its schema version; the confined CRUD primitives live in * `./store.js`. There is intentionally no execution, status-transition, or CLI * logic here — those belong to a later task. * * The record captures enough to detect later DRIFT against the profile it was * started from: {@link WorkflowRun.workflowDigest} and * {@link WorkflowRun.profileDigest} pin the def/profile identity at start, and * {@link WorkflowRun.knownStageIds} records the stage set so a later config * change can be classified rather than silently mis-resumed. */ /** Lifecycle status of a workflow run (or of one stage within it). */ type WorkflowRunStatus = "pending" | "running" | "completed" | "cancelled" | "failed"; /** Who performed a workflow event. */ type WorkflowActorKind = "human" | "agent" | "system"; /** The kinds of recorded workflow lifecycle event. */ type WorkflowEventType = "workflow-start" | "stage-advanced" | "gate-approved" | "stage-output" | "run-cancelled" | "run-failed" | "run-resumed" | "workflow-adapted" | "events-truncated" | "fields-truncated"; /** One recorded workflow lifecycle event (in-record audit trail). */ interface WorkflowEvent { /** Which kind of lifecycle event this records. */ type: WorkflowEventType; /** ISO-8601 timestamp. */ at: string; /** Who performed the event (human/agent/system). */ actorKind: WorkflowActorKind; /** Optional free-form label identifying the actor (e.g. a username or agent id). */ actorLabel?: string; /** The stage this event concerns, when stage-scoped. */ stageId?: string; /** The gate this event concerns, when gate-scoped. */ gateId?: string; /** The decision recorded (e.g. an approval verdict), when applicable. */ decision?: string; /** Optional human-readable detail about the event. */ detail?: string; /** run.stateVersion immediately before this event. */ stateVersionBefore: number; /** run.stateVersion immediately after this event. */ stateVersionAfter: number; } /** * An in-flight stage-output INTENT marker (crash-recovery dedup). Persisted BEFORE * a stage-output's external (page/relation/lifecycle) write runs and CLEARED in the * same write that records the output. Its presence on a fresh submit means a prior * submit crashed mid-apply — the external write MAY have landed un-recorded — so the * submit FAILS CLOSED (never silently re-applies). The `opId` is a deterministic * `${runId}:${stageId}:${stateVersion}` identifying the in-flight operation. */ interface PendingStageOutput { /** The stage whose output is mid-apply. */ stageId: string; /** Deterministic op id of the in-flight external write (for reconciliation). */ opId: string; } /** Per-stage progress status (distinct from the run-level status). */ type StageStatus = "pending" | "running" | "awaiting-gate" | "completed" | "failed"; /** One stage's recorded progress within a run. */ interface StageLogEntry { /** The stage id this entry reports on. */ stageId: string; /** The recorded per-stage status (may be `awaiting-gate`, unlike the run status). */ status: StageStatus; } /** A durable, core-owned workflow run record (the source of truth for a run). */ interface WorkflowRun { /** Record schema version; reads fail closed when this exceeds the known version. */ schemaVersion: 2; /** Core-minted, slug-safe, opaque run id (also the filename stem). */ runId: string; /** The id of the workflow this run executes. */ workflowId: string; /** Digest of the workflow def the run was started against (drift detection). */ workflowDigest: string; /** Digest of the whole profile at start time (drift detection). */ profileDigest: string; /** The stage ids known at start (used to classify later config drift). */ knownStageIds: string[]; /** Current lifecycle status of the run. */ status: WorkflowRunStatus; /** The stage the run is currently at, or null when none/terminal. */ currentStage: string | null; /** Append-only log of per-stage progress. */ stageLog: StageLogEntry[]; /** Append-only, in-record audit trail of lifecycle events (capped per run). */ events: WorkflowEvent[]; /** The `:` gate strings approved so far on this run. */ satisfiedGates: string[]; /** Caller-supplied inputs the run was started with. */ inputs: Record; /** * Accumulated outputs produced by the run, keyed by STAGE ID * (`outputs[stage.id]`). * * KNOWN LIMITATION — one output per stage: because the key is the stage id, a * stage records a SINGLE output ref and `advance` gates the stage on that one * `outputs[stage.id]` being present. A stage that declares MULTIPLE distinct * `writes` entity types therefore records only the FIRST submitted output and * advances on it — it cannot durably record one ref per declared write. Emitting * multiple distinct entity-type writes per stage is a FUTURE enhancement that * requires per-write output keying (e.g. `outputs[stage.id][entityType]`); today * this is an honest, documented limitation, not a silent surprise. */ outputs: Record; /** * An in-flight stage-output intent marker, present ONLY between a stage-output's * intent persist and its post-apply record. A non-absent value on a fresh submit * signals a prior crashed mid-apply (see {@link PendingStageOutput}). */ pendingOutput?: PendingStageOutput; /** Monotonic state version, bumped on each persisted mutation. */ stateVersion: number; /** * ADVISORY provenance: the caller identity that STARTED the run (M1), from * `LLMWIKI_ACTOR` or the OS username. Best-effort attribution, NOT cryptographic — * consistent with the single-machine trust model (the R3 HMAC makes the recorded * value tamper-evident). Mutating runId-bearing ops on the action surface refuse a * caller whose identity differs from a set `owner`; an OWNER-LESS (legacy) run is * unrestricted. Optional for back-compat with pre-M1 records. */ owner?: string; /** ISO-8601 start timestamp. */ startedAt: string; /** ISO-8601 last-update timestamp. */ updatedAt: string; /** * Per-record tamper-evidence: the hex HMAC-SHA256 of this record (with `integrity` * itself omitted) under the per-project `.runkey`. STAMPED by `writeRun`/ * `writeTerminalRun` and RE-VERIFIED by `readRun` — a missing or mismatched value * (a hand-edited / synced / restored / foreign-key record) fails closed. See * {@link ../workflows/integrity.ts}. */ integrity?: string; } /** * @file src/workflows/status.ts * @description The read-only `status` operation: classify run(s) against the * active profile config. * * `status` reports how each persisted run relates to the CURRENT profile, so a * config change between start and resume is surfaced rather than silently * mis-resumed. It is strictly read-only: it takes NO lock, creates nothing, and * NEVER repairs a malformed run — an unavailable/corrupt record is SURFACED as a * `problem`, not auto-fixed, and an unknown run id is a fail-closed `problem` * too (never a throw). The active profile is loaded ONCE and threaded through the * per-run classification. * * ## Classification * - `current`: the run's def digest still matches AND every known stage (and the * current stage) still exists in the active def. * - `needs-adaptation`: the def changed (digest differs) but the run's current * stage still maps into the active def. * - `blocked-by-config`: the run sits on a stage the active def no longer * declares — it cannot be acted on under the current config. Also used for an * absent/unavailable run record and an unknown run id (with a `problem`). * - `historical`: the run is terminal, OR its workflow was removed from the * profile — readable history only, not actionable. */ /** How a run relates to the current profile config. */ type RunClassification = "current" | "historical" | "needs-adaptation" | "blocked-by-config"; /** A run plus its classification (and any problem detail for an unavailable/malformed run). */ interface RunStatus { /** The run id this status reports on. */ runId: string; /** How the run relates to the current profile config. */ classification: RunClassification; /** The validated run record; present only when the run is readable. */ run?: WorkflowRun; /** Why the run is unavailable/malformed/unknown; present only when there is a problem. */ problem?: string; /** * True ONLY for a STORE-LEVEL health row — one not attributable to any single * run (the synthetic `(store)` "run store unavailable" row, or the redacted * `(unreadable)` aggregate). Such rows are GLOBAL health: a workflow-scoped view * preserves them unconditionally rather than object-scope-filtering them. A * per-run problem row (an individual unreadable/corrupt run with its real runId) * leaves this unset/false — it is attributable to SOME run and is NOT global. */ storeLevel?: boolean; /** * The gate id the run's CURRENT stage is parked on; present only for a * `current` run whose current stage's log entry is `awaiting-gate`. This is * the `` part of that stage's `gate` def — exactly what `gate approve` * takes — so a blocked run is observable without re-reading the profile. */ awaitingGate?: string; /** * True when the `awaitingGate` above is a `trust:` gate — one that CANNOT be * cleared by `gate approve` (the Trust Guard clears it on a successful write). * Lets a renderer hint the trusted-write grant + re-submit instead of a * `gate approve` that would fail. Present only alongside `awaitingGate`. */ awaitingTrustGate?: boolean; /** * True when the run's CURRENT stage is parked needing a `submit` rather than a * `gate approve`: a `current` run whose current stage's log entry is * `awaiting-gate`, whose stage declares writes OR artifactWrites (i.e. it is not * write-less), and for which no applied output has yet been recorded under * `outputs[currentStage]`. So a stage awaiting a stage-output submission is * observable without re-reading the profile. Does NOT affect classification. */ awaitingOutput?: boolean; /** * A DECLARED write entity type of the current stage, for the `awaiting-output` * `next:` submit hint (`--entity-type `). The FIRST entry of the stage's * `writes` — a concrete, valid `--entity-type` value an operator/agent can submit * against — set only alongside {@link awaitingOutput}. (One-output-per-stage: a * stage records a single output ref and advances on the first; see * `stage-output.ts`.) */ nextSubmitEntityType?: string; /** * A DECLARED artifact type of the current stage, for the `awaiting-output` * `next:` submit hint (`--artifact-type `). The FIRST entry of the stage's * `artifactWrites` — a concrete, valid `--artifact-type` value an operator/agent * can submit against — set only alongside {@link awaitingOutput}. Independent of * {@link nextSubmitEntityType}: a combined write+artifact stage sets both; an * artifact-only stage sets only this one. */ nextSubmitArtifactType?: string; } /** * @file src/workflows/adapt.ts * @description The PURE workflow-adaptation plan, the read-only `adapt --dry-run`, * and the under-lock `adaptApply` that re-anchors a run to the changed def. * * When a workflow definition evolves, an in-flight run pinned to the OLD def may * sit on a stage id that has since been RENAMED. A renamed stage declares its old * id(s) under {@link WorkflowStageDef.previousIds}, so an old id can be mapped to * the new one rather than blocking the run. This module computes that mapping: * * - {@link mapStageId} — pure + total: an id that is still a stage maps to itself; * else an id named in some stage's `previousIds` maps to that stage's id; else * `null` (unmappable). * - {@link computeAdaptationPlan} — pure (no I/O): builds the per-run plan of every * stage id the run references, partitioned into a `stageMapping` (old→new, identity * included) and an `unmappable` list, with the old/new digests and a `lossless` flag. * - {@link adaptDryRun} — READ-ONLY: loads the profile, resolves the run(s), and * returns a plan per run. It takes NO lock and performs NO write (no `writeRun`): * it is a preview only. For a caller-NAMED id, an unresolvable id AND a * resolvable-but-unreadable leaf are BOTH fail-visible throws (a named run never * vanishes as `[]`). For the bulk (all-runs) path, an individual unreadable leaf * is skipped (skip-malformed). An UNAVAILABLE run store is surfaced as a throw on * both paths (never silently treated as "no runs"). * - {@link adaptApply} — UNDER THE PROJECT LOCK (fail-closed read): re-anchors a * run to the active def. A lossless adapt remaps the current stage + stage log * and re-anchors the digest (so the run then classifies `current`); a lossy * adapt fails closed unless `confirm` (a confirmed unmappable current stage * cancels the run, the drop recorded on the `workflow-adapted` event). */ /** A read-only adaptation preview for ONE run against the current workflow def. */ interface AdaptationPlan { /** The run this plan reports on. */ runId: string; /** The workflow the run executes. */ workflowId: string; /** Digest of the def the run was started against. */ oldDigest: string; /** Digest of the CURRENT def the run would be adapted to. */ newDigest: string; /** Every mappable stage id the run references (old→new; identity included). */ stageMapping: { from: string; to: string; }[]; /** Stage ids the run references that map to `null` (unmappable). */ unmappable: string[]; /** * Wiki page refs (`/`) recorded under an UNMAPPABLE output key * that a confirmed lossy adapt would DROP — leaving the page ORPHANED (the run no * longer references it). Reported (not auto-deleted) so an operator can clean it. */ orphanedOutputs: string[]; /** True when no referenced stage id is unmappable. */ lossless: boolean; } /** * @file src/workflows/advance.ts * @description The `advance` operation for READ-ONLY / GATE-ONLY stages. * * `advance` moves an active run forward by ONE stage, under the project lock and * with a fail-closed read. A stage COMPLETES only when its work is done * ({@link stageSatisfied}): a `human:`/`agent:` gate must be in `satisfiedGates`; * a `trust:`-gated stage's gate must be in `satisfiedGates` (set by a successful * {@link submitStageOutput} apply, NOT by `gate approve`); a stage declaring * non-empty `writes` ADDITIONALLY needs its output recorded in * `run.outputs[stage.id]`. A stage with neither a gate nor writes is trivially * satisfied. * * When NOT satisfied the current stage is PARKED, not crashed. An unmet * `human:`/`agent:` gate parks `awaiting-gate` (the existing * {@link parkAwaitingGate} path). An unmet applied-output and/or unsatisfied * `trust:` gate — the cases a {@link submitStageOutput} call clears — parks with * the `awaiting-output` outcome (the stage-log marker reuses the `awaiting-gate` * status, since there is no separate per-stage status for a pending output; the * write/output-awaiting distinction lives at the OUTCOME level only). * * When satisfied the current stage is marked `completed`, the run steps to the * next stage (`running`) or, if that was the last stage, completes. The version * bump + `stage-advanced` event are stamped atomically via {@link appendRunEvent}; * the stage/status edits are applied to its result, then persisted through the * confined store. */ /** The result of advancing a run by one stage. */ type AdvanceOutcome = "advanced" | "completed" | "awaiting-gate" | "awaiting-output"; /** A persisted run plus the outcome of the advance that produced it. */ interface AdvanceResult { /** The run as persisted after the advance. */ run: WorkflowRun; /** Whether the run advanced, completed, or is now awaiting a gate/output. */ outcome: AdvanceOutcome; } /** * @file src/workflows/stage-output-internals.ts * @description The under-lock apply engine shared by the stage-output arms. Split * out of `stage-output.ts` so BOTH it (the page/relation/lifecycle arms) and its * sibling `artifact-output.ts` (the artifact arm) reuse the SAME atomicity + * trust-gate primitives WITHOUT an import cycle between the two arm modules. * * It owns the pre-validate → apply → record discipline ({@link preflightApplyRecord} * and its intent-marker helpers) and the `trust:`-gate refusal for the non-page * kinds ({@link guardTrustGatedNonPageWrite}) — the load-bearing invariants every * applied write depends on. These carry the run-store I/O; the arm modules layer * their kind-specific scope guards + planner/executor routing on top. */ /** The result of submitting a stage output. */ interface SubmitResult { /** The run as persisted after the submission. */ run: WorkflowRun; /** Whether the write landed LIVE (true only on an `allow`/`allow-with-warning`). */ applied: boolean; /** The composed Trust Guard decision for the write. */ decision: TrustDecision; } /** * @file src/workflows/artifact-output.ts * @description The `artifact` arm of the stage-output seam — split out of * `stage-output.ts` (which reached its file-size budget) so the artifact-specific * types, scope guard, M2 immutability guard, and apply path live together. * * The arm is scope-gated on the stage's `artifactWrites`, refuses a trust-gated * write without the out-of-band operator grant (like relation/lifecycle — artifacts * have no staged-review path), and enforces M2 immutability * ({@link assertArtifactImmutable}) BEFORE routing the write through the executor's * under-lock artifact authority. It reuses the atomicity + trust-gate primitives * from `stage-output-internals.ts` ({@link preflightApplyRecord}, * {@link guardTrustGatedNonPageWrite}, {@link WORST_CASE_DECISION}) — shared with the * page/relation/lifecycle arms with NO import cycle between the two arm modules — so * the pre-validate → apply → record discipline is identical across every kind. */ /** An artifact output: write `body` as the typed artifact `artifactType/slug`. */ interface ArtifactStageOutput { kind: "artifact"; artifactType: string; slug: string; body: string; } /** * @file src/workflows/stage-output.ts * @description Scope-gated PAGE stage-output submission — the seam that wires a * write-declaring workflow stage into the planner→executor write path. * * A stage that declares a non-empty `writes` set does NOT advance on its own * (that is `advance`'s read-only/gate-only job): it advances when the caller * SUBMITS a typed output for it. This module handles four output kinds — `page`, * `relation`, `lifecycle-transition`, and `artifact` — each routed through the * planner→executor seam under the same scope guard + lock discipline. The * `artifact` kind is scope-gated on the stage's `artifactWrites` and stamps a * harness-controlled {@link WorkflowArtifactOrigin} the caller can never forge. * * SECURITY MODEL (two load-bearing invariants): * * 1. SCOPE. A stage may write ONLY the entity types it declares in `writes`. The * scope guard runs BEFORE any planning or I/O: an output naming an entity type * outside the current stage's `writes` is refused with {@link StageWriteScopeError} * and the run is left byte-unchanged. (A traversal-bearing slug never reaches * the planner's slug-safe floor when its type is out of scope — the scope guard * short-circuits first; an in-scope traversal slug is still rejected by the * planner's identity floor as a decision, never a thrown exception.) For a * `relation` output the scope unit is the endpoint entity TYPES (the `` * half of each `/` endpoint EntityId): BOTH must be in `writes`. For * a `lifecycle-transition` output the scope unit is the target `entityType`. * * RELATION/LIFECYCLE APPLY (no pre-decision). Unlike the page path — which plans a * decision the seam then branches on — the relation and lifecycle kinds route to * the executor's UNDER-LOCK authority ({@link applyApprovedMutationsLocked}), which * re-loads the profile, re-plans, re-composes the decision, and EITHER applies * (success) OR THROWS a denial ({@link RelationWriteDeniedError} / * {@link LifecycleTransitionError}). There is no staged/`deny` branch here: a * thrown denial propagates with the run byte-unchanged and NO gate satisfied; a * successful apply records the output, satisfies a `trust:` gate, and reports the * REAL decision the under-lock authority composed (`allow`/`allow-with-warning`), * threaded back through the {@link ApplyResult} rather than a hardcoded literal. * * ATOMICITY (durability boundary): an applied write is PRE-VALIDATED before the * external mutation runs — the projected run record (with the `stage-output` event * appended, enforcing the event-count cap, and sized against the run byte cap) is * validated FIRST, so a cap violation throws BEFORE any page/relation/lifecycle * write. This eliminates the silent-unaudited-write class where the live mutation * lands but the workflow output/gate can never be recorded. * * IDEMPOTENCY (applied-once): a stage produces its output AT MOST ONCE. Under the * lock, BEFORE dispatch, the seam refuses a re-submission whose output is already * recorded ({@link StageOutputAlreadyAppliedError}) — no second external write. To * make a crash between the external apply and the output record VISIBLE rather than * a silent orphan, an INTENT marker (`run.pendingOutput`) is persisted BEFORE the * apply and CLEARED in the record-output write; a fresh submit that finds an * un-cleared marker for the current stage fails closed ({@link StageOutputPendingError}) * so an operator reconciles whether the prior write landed, never an auto re-apply. * (The full cross-store op-id journal that would AUTO-reconcile is a documented * residual; this makes the orphan non-silent and blocks the auto-duplicate.) * * 2. NO PRIVILEGED CHANNEL. The write is planned with `reviewRouted:true` and * `origin:"workflow"` — a stage output is NOT a trusted write channel. A * BLOCKED write is PARKED (recorded as `stage-for-review`) rather than * landing, never silently applying. `stage-for-review`/`quarantine` stay * blocked (the gate is NOT satisfied) and `deny` writes nothing at all * ({@link StageWriteDeniedError}). * * TRUST-GATE SAFETY (C3). A `trust:` stage gate means "trusted review * required", NOT "well-formed ⇒ live". For a `trust:`-gated PAGE stage WITHOUT * an out-of-band operator grant ({@link isTrustedWriteGranted} — * `LLMWIKI_TRUSTED_WRITE`), even a clean `allow` is DOWNGRADED to PARKED: the * write does NOT go live and the trust gate is NOT satisfied. The gate is * satisfied ONLY when the operator grant auto-applies the write (their explicit * out-of-band choice). HONESTY: parking records ONLY a run event — it creates * NO `.llmwiki/candidates` review item, so `review list`/`approve` cannot see * or promote a workflow-parked output (wiring a real review-promotion path * that also satisfies the gate is a deferred capability); today a parked trust * output leaves the stage blocked until the operator grant re-applies it. This * closes the "any valid markdown auto-satisfies a trust gate" exploit. A * non-`trust:` stage keeps the normal apply-on-`allow` behavior. * * The RELATION and LIFECYCLE kinds are not parked at all. For a `trust:`-gated * relation/lifecycle stage WITHOUT the grant, the write is REFUSED outright * ({@link TrustGateRequiresGrantError}): nothing is written, the gate is NOT * satisfied, the run is byte-unchanged. The operator grant is the ONLY way * such a write proceeds. So on ALL three kinds, a clean well-formed output can * NEVER auto-satisfy a trust gate without the grant — pages PARK (not * applied), relation/lifecycle REFUSE. * * The whole read→scope-guard→plan→apply→writeRun sequence runs under ONE * bounded-blocking project lock (released in `finally`), mirroring the relation * write seam — so a concurrent writer serializes rather than tearing this run. * * KNOWN LIMITATION — ONE output per stage. A recorded output is keyed by STAGE ID * (`outputs[stage.id]`; see {@link projectAppliedRun}), and `advance` gates a * write-declaring stage on that single key being present. So a stage that declares * MULTIPLE distinct `writes` entity types records only the FIRST submitted output * and advances on it — it cannot emit one durable output ref per declared write. * Per-write output keying (e.g. `outputs[stage.id][entityType]`) is a documented * FUTURE enhancement; today a stage produces a single output ref. See * {@link WorkflowRun.outputs}. */ /** A page output: write `body` to the entity page `entityType/slug`. */ interface PageStageOutput { kind: "page"; entityType: string; slug: string; body: string; } /** A relation output: create the typed relation `input` (endpoint types must be in scope). */ interface RelationStageOutput { kind: "relation"; input: AppendRelationInput; } /** A lifecycle-transition output: move `entityType/slug` to `toState`. */ interface LifecycleStageOutput { kind: "lifecycle-transition"; entityType: string; slug: string; toState: string; evidence?: Record; } /** The typed output a caller submits to advance a write-declaring stage. */ type StageOutput = PageStageOutput | RelationStageOutput | LifecycleStageOutput | ArtifactStageOutput; /** * @file src/workflows/run-action.ts * @description `runAction` — execute a declarative workflow action UNDER the * composed authority, then dispatch to the existing run-lifecycle op. * * This is the SECURITY-CRITICAL execution core. It adds NO new write path: every * mutation flows through an EXISTING op ({@link startWorkflow} / {@link * resumeWorkflow} / {@link advanceWorkflow} / {@link cancelWorkflow} / {@link * approveGate} / {@link workflowStatus}), each of which keeps its own project * lock and Trust Guard. `runAction` only (1) validates inputs against the action's * `inputSchema` (PURE, fail-closed), (2) composes the EFFECTIVE permission = * `min(profile request, local grant, surface cap)`, (3) ENFORCES the operation's * required capability against that effective permission — RANKED on the ordinal * {@link CAPABILITY_ORDER}, never string-compared — and (4) dispatches. * * The op→capability contract enforced here: * - `status` → read-only * - `start` / `resume` / `advance` / `cancel` → staged-write * - `gate` (human) → cli surface ONLY, operator-enabled, AND the INTERACTIVE TTY * proof (the same one the direct `gate approve` uses) — never satisfiable on a * PROGRAMMATIC surface (sdk/mcp/viewer) * - `gate` (agent) → staged-write * * A `disabled` effective permission denies EVERY operation. An effective permission * below the required rank is refused with {@link ActionDeniedError} BEFORE any op * runs. A `human:` gate is denied here on any non-cli surface, and at dispatch unless * the interactive proof passes — so there is EXACTLY ONE way to satisfy a human gate * (an interactive cli confirmation), whether reached via `gate approve` or `action * run`. The action surface never calls `approveGate(actorKind:"human")` without it. * * On top of authority, a MUTATING runId-bearing op (resume/advance/cancel/gate) * enforces run OWNERSHIP (M1) via the SHARED {@link assertRunOwnership} — the same * guard the direct ops run under their lock — so caller B cannot mutate caller A's run * on ANY surface. A read-only by-id `status` is NOT owner-gated (cross-owner reads are * permitted observability). */ /** The result of executing a workflow action under the composed authority. */ interface ActionRunResult { /** The declared action id that was executed. */ actionId: string; /** The workflow operation the action resolved to. */ operation: string; /** The COMPOSED effective permission the action ran under. */ effectivePermission: CapabilityClass; /** The underlying run-lifecycle op's result. */ result: unknown; } /** * @file src/workflows/projection.ts * @description A DERIVED, one-way markdown projection of a workflow run. * * A workflow run is core-owned JSON (the SOURCE OF TRUTH, persisted under the * private `.llmwiki/` dir; see `./store.js`). When a workflow def declares a * `projectionFile` (a `wiki/...` path confined under `wiki/` at profile-load), * `workflow project` renders a human-readable markdown view of the run to that * path. The projection is DERIVED: it is computed FROM the run JSON and is a * `wiki/` OUTPUT, not run state. Editing the markdown can NEVER affect the run — * nothing in this module (or in validation/read) ever consumes the markdown back * into a record; every status/read path reads the run JSON, never the projection. * * ## Confinement (no page-clobber) * The profile validator confines `projectionFile` to the RESERVED projection * subtree (`wiki/outputs/workflows/`) at LOAD, so a `projectionFile` can never * name an authored entity page. On top of that, the resolved write path is * RE-CONFINED here ({@link confineProjectionPath}) before it reaches * {@link atomicWrite}: a path that escapes `/wiki/` fails CLOSED. As a final * defense in depth, {@link writeProjection} refuses to overwrite a target that * EXISTS but is NOT already a projection (no `