# P2 Host Workspace Preprocessing CLI Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement P2/D2 as a native `thothctl workspace` interface that runs the existing preprocessing engine in a hardened one-shot container, derives its configuration from one active schema-v3 Git workspace revision plus installation-local bindings, and requires no Python, Node, Pi, or running backend HTTP service on the host. **Architecture:** `thothctl` validates a closed command grammar, reconstructs the exact installation Compose project, resolves the selected core image to an immutable Docker image ID, and starts only the profile-gated `workspace-maintenance` service with `--no-deps`. A compiled Node entrypoint reads an already-active immutable registry snapshot, uses the same binding resolver and runtime renderer as sessions, writes a deterministic revision-owned protected harness config, and invokes fixed existing `tht` commands. A versioned coordinator state and one kernel-released writer lock serialize mutation, preserve outer/child resume identity, and stop at a digest-bound FK review checkpoint. **Tech Stack:** Go 1.26.5 (`thothctl`; matches `go.mod` `toolchain go1.26.5`), Docker Compose v2, Node.js 22, TypeScript 5, Python 3.12, Typer, Pydantic 2, Qdrant 1.18.2, the internal Ollama-compatible embedding interface, Vitest, pytest, Bash/Node acceptance tooling. **Source PRD and design:** `docs/prd/2026-08-09-workspace-preprocessing-prd.md` D2/P2, RF1.2–RF1.4, RF2, RF3.1, RF4.1, RF5.2, RF8.5–RF8.6, RNF1–RNF9; `docs/superpowers/specs/2026-08-10-p2-p6-workspace-preprocessing-design.md` §§1–4, 9–10; `docs/testing/p2-p6-manual-verification.md` P2. **Planning status:** DESIGN/PLAN ONLY. Do not change production code, start P2 implementation, or create a persistent implementation goal until the reviewer asks for plan validation and then gives explicit implementation approval. --- ## P2 completion contract P2 is complete only when all of the following are true: 1. The only public host interface is the installed native `thothctl` binary. Docker/Compose is required, but host Python, Node, Pi, `tht`, and a running Fastify backend are not. 2. `workspace inspect` consumes the active validated snapshot or, only when the repository-locked recheck proves no active state exists, submits the `registry_bootstrap` variant to the same addressed publication owner used by pull, lazy list, status, and author-publication activation. A queued caller that observed earlier absence returns `already_active` with the locked recheck's exact snapshot; corrupt active state fails closed. Bootstrap has no base and its complete changed set is every target workspace ID. Mutating preprocessing commands require that pinned active snapshot and never pull. Every command binds the exact workspace ID, 40-hex commit, descriptor blob/digest, installation bindings, runtime roots, and selected internal semantic contract before mutation. 3. Operator and session configuration use the same `resolveRuntimeBindings` and `renderRuntimeConfig` implementation. P2 renders byte-identical YAML for the same snapshot/bindings in both modes and uses one deterministic same-revision config-source path so current schema-v1 DWH/Evidence resume works. Every registry-rendered config, session and maintenance alike, sets `vectors.collection_lifecycle: require_existing`; only legacy non-registry configs retain the old create-capable default. P3 later introduces cross-revision canonical effective identity and explicit migrations. 4. DWH introspection+LSH, FK suggestion/check, schema indexing, and HTTP Evidence preprocessing invoke the existing harness engine through fixed argv and pristine JSON machine interfaces. No second preprocessing engine is added. 5. A full run with new FK candidates stops before schema/Evidence writes. Continuation requires a reviewer-supplied annotations file and an explicit acknowledgement of the exact candidate digest; `schema check` alone is not treated as human approval. 6. Mutating P2 operations are safe while roots and semantic point IDs are still workspace-global: under the workspace writer lock they refuse if any resumable session is pinned to a different workspace revision. Registry bootstrap/pull/author-publication activation uses one repository-first addressed owner and `runUnderOrderedWorkspaceWriterLocks`: it holds the verified root and writer FDs for every changed workspace in lexical order through quiescence/readers, participants, publication, and terminal durability, then closes in reverse. Therefore the active revision cannot change during a mutating publication window. P3 removes the temporary session restriction by introducing revision-scoped curated/semantic state. 7. P2 never creates, repairs, deletes, or rebuilds a Qdrant collection. Schema/Evidence writes require an already-existing, exactly compatible collection and a harness `require_existing` mode that cannot race into auto-create. P4 owns lifecycle reconciliation. 8. HTTP Evidence is operational only under installation-local egress policy. Private hosts require an exact installation allowlist; redirects are rechecked; metadata/link-local targets are always refused. S3 custom/private/insecure endpoints and ambient credentials remain fail-closed in P2 unless a later separately reviewed plan expands policy. 9. Filesystem Evidence is rendered but execution stops before discovery with `evidence_materialization_required` and no partial corpus/vector publication. P6 owns materialization and symlink/containment checks. 10. `postgres_direct` and `rest_api` routing remain supported and are regression-tested; the clean P2 process goal uses controlled REST. `ssh_tunnel` returns a stable fail-closed result until P10. 11. One clean-state product-path integration command passes without retry, produces retained machine/human reports and a secret scan, and proves exact cleanup. Manual P2 acceptance remains independent and PENDING. 12. Work stops after the P2 handoff. No P3 work begins without a new explicit user authorization. ## Truthful command status at the P2 checkpoint | Command | P2 status | Deliberate boundary | |---|---|---| | `workspace inspect` | Operational | Reads active snapshot; under `repository.lock`, it first revalidates active state and returns `already_active`, otherwise an empty registry automatically resumes its sole exact-identity nonterminal bootstrap or creates one; it never pulls an existing active state | | `workspace preprocess dwh` | Operational for REST/direct | Same-revision config identity; cross-revision reuse is P3 | | `workspace schema suggest-fks` | Operational, machine-safe | Candidate export only; no automatic human acceptance | | `workspace schema check` | Operational | Requires the exact outer `--resume` run, imports reviewed annotations, and records a digest-bound local P2 acknowledgement | | `workspace index-schema` | Operational with compatible pre-existing collection | Collection create/repair/rebuild is P4 | | `workspace preprocess evidence` | Operational for policy-allowed HTTP; filesystem deferred | Filesystem materialization is P6; S3 expansion needs separate policy review | | `workspace preprocess run` | Operational with FK checkpoint | Git-canonical annotations are P5; revision-global writes use the P2 session-inventory guard | P2 is therefore the host CLI/orchestration checkpoint, not final acceptance of the PSD filesystem path or the complete PRD chain. ## Frozen host command grammar ```text thothctl --installation /thothii-installation.yaml workspace inspect --workspace [--json] thothctl ... workspace preprocess dwh --workspace [--resume ] [--json] thothctl ... workspace schema suggest-fks --workspace [--from-sql ]... [--assume ]... [--output ] [--json] thothctl ... workspace schema check --workspace --resume <32-hex-outer-run-id> [--annotations --reviewed-candidates ] [--json] thothctl ... workspace index-schema --workspace [--json] thothctl ... workspace preprocess evidence --workspace [--dry-run] [--resume ] [--json] thothctl ... workspace preprocess run --workspace [--resume ] [--json] ``` Rules: - `--workspace` occurs exactly once and matches `[a-z][a-z0-9-]{2,62}`. - All run IDs are 32 lowercase hex characters and identify outer P2 state, never a path or raw child checkpoint. `schema check` always requires the exact `--resume <32-hex-outer-run-id>` returned by `schema suggest-fks` or the blocked full run; the candidate digest is not a run selector and no automatic digest lookup is permitted. - At most 32 `--from-sql` files, 1 MiB each and 16 MiB total. `thothctl` opens each as a canonical regular non-symlink/reparse-point file, rechecks identity after reading, and streams a schema-versioned request over stdin. No host directory is mounted. - `--assume` occurs at most 256 times; each value is at most 256 bytes and is validated before Compose. - `--annotations` is a single UTF-8 YAML file, at most 16 MiB. `--reviewed-candidates` is mandatory with it and must equal the persisted candidate artifact digest. The pair is invalid without both flags. - `--output` is created exclusively with restrictive permissions after the returned workspace/run/digest identity has been verified. Existing files, symlinks, hardlinks, and Windows reparse targets are refused. - Existing harness `suggest-fks --write` is intentionally not exposed: an automatic merge is not a human review decision. - Bootstrap recovery is automatic and installation-scoped; `workspace inspect` deliberately has no public bootstrap `--resume` selector. Inspect, lazy list, and status all invoke the same bounded `ensureBootstrapAddressed` selector under `repository.lock`; it rechecks valid/corrupt/absent active state before any job scan or bootstrap network operation, so queued stale-absence callers return `already_active`. - No unknown flag, passthrough separator, environment-selected command, shell fragment, or arbitrary container entrypoint is accepted. ## Public result and exit contract The one-shot entrypoint always emits exactly one bounded schema-versioned JSON object. `thothctl --json` parses it strictly and re-encodes it, so Compose progress cannot contaminate stdout. Human mode renders only allowlisted fields. ```ts interface WorkspaceOperationResult { schemaVersion: 1; status: "succeeded" | "unchanged" | "dry_run" | "blocked" | "failed"; code: | "ok" | "workspace_not_found" | "workspace_not_activatable" | "binding_missing" | "preprocessing_conflict" | "preprocessing_resume_mismatch" | "manual_review_required" | "evidence_materialization_required" | "effective_config_mismatch" | "semantic_index_incompatible" | "annotation_invalid" | "egress_policy_refused" | "registry_bootstrap_recovery_conflict"; workspaceId: string; workspaceRevision: string; descriptorBlob: string; operation: string; runId?: string; childRuns?: Record; completedStages: string[]; counts?: Record; artifactIdentities?: Array<{ kind: string; digest: string }>; warnings?: string[]; } ``` - Exit `0`: `succeeded`, `unchanged`, or `dry_run`. - Exit `3`: expected operator checkpoint/block (`manual_review_required`, `evidence_materialization_required`, lock/revision conflict, or `registry_bootstrap_recovery_conflict`). The Go JSON encoder preserves that exact code; human mode prints only `Bootstrap recovery is ambiguous or corrupt; inspect the installation registry jobs.` and never selects or prints a candidate/newest run ID. - Exit `2`: host grammar or unsafe local file error. - Exit `1`: operational failure. - The existing lazy-list/status HTTP shapes stay unchanged; automatic recovery conflict is HTTP `409` with `{ "code": "registry_bootstrap_recovery_conflict" }` and no run ID, filename, raw parser error, repository path, or remote detail. - Stdout JSON maximum: 1 MiB. Sanitized stderr maximum: 64 KiB. Child stdout/stderr and every stage have explicit limits/timeouts. - Never return descriptor endpoints with credentials/query strings, secret contents or paths, signed URLs, raw SQL, rendered configuration, raw child stderr, arbitrary exception text, Qdrant payload contents, or host/container environment dumps. ## P2 state and identity layout ```text /data/sessions//preprocessing/ ├── writer.lock ├── runtime-config/ │ └── <40-hex-revision>.yaml ├── runtime-config-manifests/ │ └── <40-hex-revision>.json ├── jobs/ │ └── <32-hex-outer-run-id>.json ├── fk-candidates/ │ └── <32-hex-outer-run-id>.yaml └── fk-reviews/ └── <32-hex-outer-run-id>.json ``` - `workspace-lock-root-lease.ts` is the sole producer of an exported, non-forgeable `VerifiedWorkspaceLockRootLease`. Its factory is bound at construction to the installation-derived sessions root, accepts only a factory-produced `CanonicalWorkspaceLockRootInput` for a canonical workspace ID (never a request path), opens every component with no-follow directory semantics, and retains the workspace-root directory FD plus device/inode identity until the reader or writer lifetime ends. A structurally forged/cast input, an input from another factory, a changed root inode, a closed/consumed lease, or a root replacement between derivation, open, and lock-file open fails `preprocessing_conflict`. Writer, session admission, route, and maintenance code consume the exact operations on this lease/capability contract rather than branding/casting strings or recovering the retained root. - For a changed-set ID whose canonical leaf does not exist, only `VerifiedWorkspaceLockRootLeaseFactory.acquireOrProvision` may create it, and only while the caller already holds `repository.lock`. The factory retains and verifies the installation sessions-parent FD, takes its private parent provisioning lock, uses the repo-owned `workspace-fs-at` Node-API seam for literal `mkdirat(parentFd, workspaceId, 0700)` plus `openat(..., O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC)`, requires the exact service UID/mode, fsyncs the new leaf and verified parent, and handles `EEXIST` only by opening and revalidating the winning directory. Symlink, non-directory, wrong-owner/mode, parent replacement, or a different inode at any recheck fails. An unused canonical leaf created for a pull that later fails is deliberately retained; it contains no published workspace data and a later add/remove retry reuses and reverifies it. Ordinary existing-root callers continue to use `acquire`; registry changed-set acquisition uses `acquireOrProvision` for every ID, including absent added and never-used removed roots. - The FD-relative seam is concrete and frozen: raw C++ source `backend/native/workspace-fs-at/workspace_fs_at.cc`, `binding.gyp` with `NAPI_VERSION=8`, type declaration `backend/src/native/workspace-fs-at-binding.d.ts`, sole TypeScript wrapper `backend/src/workspaces/workspace-fs-at.ts`, and build driver `backend/scripts/build-workspace-fs-at.mjs`. It exposes only typed `openat`, `mkdirat`, no-follow `fstatat`, directory `fsync`, explicit `close`, the closed lock-file name union, and typed owned-handle flock/child-stdio operations. The raw numeric FD borrow is module-private to `workspace-fs-at.ts` and is authorized only inside `flockOwnedLock` and `duplicateForChildStdio`; neither operation returns a number or passes one to its caller. It never accepts an arbitrary flags integer or multi-component relative name. `backend/package.json` and `backend/package-lock.json` pin `node-gyp@11.2.0`, require Node 22, and build the addon from committed source; no downloaded/prebuilt addon is accepted. - `writer.lock` and the P3 handoff file `session-readers.lock` are the exact `LockFileName` values. Each is a regular `0600` file opened relative to the retained root FD by `workspace-fs-at` with `O_RDWR|O_CREAT|O_NOFOLLOW|O_CLOEXEC` and verified through its `fstatat`/owned-FD stat result as a single-link regular file owned by the service UID. `flockOwnedLock` is the only TypeScript locking boundary: it synchronously borrows the owned regular-file FD inside the wrapper module and calls exact `fs-ext@2.1.1.flockSync` with the typed shared/exclusive plus blocking/nonblocking combination (`sh`, `ex`, `shnb`, or `exnb`). Writer acquisition uses exclusive/nonblocking and holds it through the entire outer operation. Before and after lock open/flock, the opener verifies that the canonical pathname still names the retained root device/inode. `fs-ext` is never credited with `openat`, `mkdirat`, `fstatat`, directory fsync, or close. `runUnderWorkspaceWriterLock` consumes one root lease. The sole multi-lock producer is `runUnderOrderedWorkspaceWriterLocks(leases, action)`: it rejects duplicate lease identities, sorts a private copy, acquires each writer lock in lexical workspace-ID order, retains every root and writer FD until `action(set)` settles, invalidates the opaque set and every capability before returning, and closes writer/root ownership in reverse order on success, throw, cancellation, or partial acquisition. Neither API permits nested public acquisition. - `VerifiedWorkspaceLockRootLease.acquireSessionReadersShared()` is the only session-side reader-gate acquisition. It takes no name, path, handle, flags, FD, or mode parameter. It opens exactly `session-readers.lock` at `0600` through its retained root and the private `WorkspaceFsAtV1` instance, calls `flockOwnedLock(owned, "shared", "nonblocking")`, and repeats the root/path identity checks. After an open/flock/root-recheck failure, any opened lock is closed exactly once; if that cleanup succeeds, the root owner remains live so its caller can close or deliberately retry it. If cleanup close is uncertain, the method instead invalidates the root owner, attempts its close exactly once, and rejects fail-closed. Only after all checks and flock succeed does the method atomically consume the root owner and return a `WorkspaceSessionReadersLockLease` owning both the shared lock open description and retained root. That lease is the transferable/closable owner passed into the Pi session owner and remains held until every Pi/session child and stream has settled. `transfer()` requires a live owner, invalidates its source without closing either open description, and returns the sole new owner; `close()` on that transferred source is an idempotent no-op, while another transfer rejects. Owning `close()` invalidates first, is idempotent, attempts lock close and then root close exactly once even if the first close throws, and never retries an uncertain close. Acquisition/root-race/lock-contention/transfer-after-invalid failures and any close uncertainty reject as stable `preprocessing_conflict`; a failed acquisition never returns a lease, and a close failure leaves the lease invalid rather than reusable. - `WorkspaceWriterLockCapability.runUnderSessionReadersExclusive(action)` is the only maintenance/registry-side reader-gate acquisition. It is callable only on the live callback-scoped writer capability, takes no lock argument, and through that capability's retained root and private `WorkspaceFsAtV1` opens exactly `session-readers.lock` at `0600`, then calls `flockOwnedLock(owned, "exclusive", "nonblocking")`. It rejects acquisition while a child spawn is already in flight and rejects nested/concurrent reader-gate acquisition; while `action` is live, the same capability's closed-union `spawnChild` remains usable and the reader lock is held through that child settlement. Contention or open/flock failure closes any opened lock once and never calls `action`. On success it passes only a private-constructor `BorrowedWorkspaceSessionReadersExclusiveLockLease` containing diagnostic workspace/root identities and `assertLive()`—never the directory handle/path/FD/lock name—holds the exclusive lock until the returned/raised action settles, invalidates the borrowed lease first, and closes the lock exactly once. The retained root stays owned by the outer writer capability. A callback rejection is rethrown after successful cleanup; close uncertainty instead rejects as stable `preprocessing_conflict`, retains the callback error only as a non-serialized cause, permanently poisons the writer capability, and forces its outer ordered owner to fail after completing writer/root cleanup. Captured leases fail `assertLive()` after settlement. Registry quiescence owners must call this operation rather than construct `BorrowedWorkspaceSessionReadersExclusiveLockLease` or open/flock independently. - Every harness child that can migrate or publish is spawned only by `WorkspaceWriterLockCapability.spawnChild`. Internally, and without exposing either number or an accessor, it installs the *actual locked writer open file description* as child FD 3 and the same retained verified workspace-root directory FD as child FD 4 for every mutating child. The capability accepts only the closed, exhaustively switched `WorkspaceLockedChildRequest` union already validated by the coordinator; P3, P5, and P6 extend that same union with exact variants and no raw path/argv member. Python `require_workspace_writer_lock(cfg)` first validates FD 4 as the expected retained root directory, opens `preprocessing/writer.lock` relative to FD 4 with no-follow semantics, proves it is the same device/inode as FD 3, and calls `fcntl.flock(3, LOCK_EX|LOCK_NB)` before mutation. Missing/closed/substituted/cross-root FD 3 or FD 4, pathname replacement after acquisition, a forged marker, ordinary direct CLI invocation, post-settlement capability use, or a request for another workspace returns `preprocessing_conflict` before writes. There is no raw-root, ambient-root, direct-spawn, or general-process overload. - Lock order is session admission: verified root lease → shared session-reader lease → Pi/session children; ordinary mutation: verified root lease → P2 workspace writer lock → quiescence/drain → `runUnderSessionReadersExclusive` → existing harness stage lock. Harness code verifies inherited writer/root FDs but never opens/acquires a different P2 lock, preventing inversion/deadlock. The addressed registry exception is repository lock → durable request claim → durable advertised target pin → exact-OID fetch into a run-specific immutable ref → immutable base/target plan → `runUnderOrderedWorkspaceWriterLocks` for the complete changed set → each changed workspace's quiescence and `runUnderSessionReadersExclusive` callback → participants → publication → terminal durability, all inside the ordered-owner callback. Nested exclusive callbacks acquire in the already-frozen lexical workspace order and then invalidate/close reader borrows in reverse order before quiescence/writers/roots and repository release. - Every directory component is opened/validated without following symlinks. State files are `0600`, written to an exclusive sibling, fsynced, renamed, and parent-fsynced. Hardlink count must be one. - The deterministic config path fixes P2 same-revision `config_source` identity. Its manifest binds workspace, revision, descriptor blob, config SHA-256, file identity, and the current existing harness ownership binding. Session and maintenance leases for equal inputs must have byte-identical YAML and byte-identical `config_dwh_binding()` output. Same path + different bytes returns `effective_config_mismatch`; P3 introduces semantic cross-revision equivalence. - Job state binds operation, revision, descriptor blob, config digest, non-secret binding identity, completed stage records, child run IDs, candidate/review digests, and terminal status. Registry addressed state additionally owns the complete request, advertised OID, immutable-ref identity, full base/target plan, participant/synchronizer digests, publication bytes, and terminal result in P2; P3 consumes rather than reinvents that record. - Before any schema/Evidence mutation, enumerate resumable session manifests for the workspace. A different pinned revision returns `preprocessing_conflict`; no write begins. Every product bootstrap caller—inspect, lazy list, and status—delegates to `WorkspaceRegistry.ensureBootstrapAddressed`; while continuously holding `repository.lock`, that one method first revalidates active state, returns `already_active` or fails on corruption before any job scan, and only for proven absence performs the bounded exact job scan and calls the same private addressed executor used by `publishAddressed`. Pull, author-publication activation, and internal active-pointer change delegate to `WorkspaceRegistry.publishAddressed`; both paths use the same callback-scoped `CapabilityAwareRegistryPublicationLifecycleOwner`, and no old `bootstrap`, `pull`, `activate`, or direct pointer writer remains callable. Bootstrap uses no base and all target IDs; pull/author publication use the exact base/target symmetric changed set. All registry state, root provisioning, quiescence/readers, participant/synchronizer work, pointer publication/reconciliation, and `terminal_durable` persistence occur before the ordered callback settles. This is the explicit P2 bridge and frozen P3–P6 handoff. ## One-shot service security contract `workspace-maintenance` is a dedicated profile service, not `compose run core`: - same exact selected core image, resolved to its immutable local image ID; generated final override uses that ID and `pull_policy: never`; - `docker compose run --rm --no-deps --no-TTY --name workspace-maintenance ...`; - no `build`, frontend, published port, Pi auth, Pi state, Pi trust initialization, Docker socket, home credential directory, or arbitrary command; - non-root `10001`, `read_only: true`, `cap_drop: [ALL]`, `no-new-privileges:true`, restrictive tmpfs; the registry volume is writable only for inspect's empty-registry bootstrap and read-only for every mutating operation; the sessions root is writable; - only operation-required credentials are mounted: Git transport files only for the inspect capability that may bootstrap an empty registry, DWH files only for DWH/full-run, and Evidence files only for the selected Evidence/full-run source; AWS ambient environment is cleared; - the exact installation-local setting is `THT_HTTP_PRIVATE_HOST_ALLOWLIST`, a comma-separated list of at most 32 lower-case DNS hostnames, each at most 253 ASCII bytes. Empty means no private host. Whitespace, empty entries, IP literals, wildcard/glob syntax, trailing dots, duplicates, IDNA ambiguity, and non-canonical hostnames are refused before Compose. The same validated list is passed to both core session rendering and maintenance rendering as `egress.http_private_host_allowlist`, so their YAML and schema-v1 binding stay equal; - semantic commands require already-running healthy Qdrant/embedding services and do not start/stop them; DWH/inspect commands do not start dependencies; - exact operation labels and container identity are recorded. Cancellation terminates the process group, verifies the owned container labels/image, removes only that container, and preserves all pre-existing services, volumes, and networks; - fully rendered Compose is validated before launch, and post-run container/image identity is checked before accepting output. --- ## Target file map **Native host CLI** - `tools/thothctl/cmd/thothctl/main.go`, `main_test.go`: public grammar/help, dispatch, exit codes. - Create `tools/thothctl/internal/workspaceops/operations.go`, `operations_test.go`: immutable image resolution, generated override, bounded Compose run, cancellation cleanup, JSON validation. - Modify `tools/thothctl/internal/compose/runner.go`; create/update platform process-group files and tests: streaming bounded capture and immediate process-group termination on overflow. - `tools/thothctl/internal/config/installation.go`, `installation_test.go`: maintenance service override and operation-specific binding discovery. - `tools/thothctl/internal/safeio/files.go`, platform files/tests: bounded no-follow input and exclusive output. - `tools/thothctl/internal/output/sanitize.go`, tests: bounded redaction. - `tools/thothctl/internal/pi/update.go`, tests: selected image override must pin both `core` and `workspace-maintenance`. **Compose/image boundary** - `compose.yaml`: dedicated profile-gated service with shared image identity and least privilege. - `deploy/compose.local.yaml`, `deploy/compose.server.yaml`: correct registry/session storage semantics. - `deploy/compose.git-https.yaml`, `deploy/compose.git-ssh.yaml`: expose validated Git transport metadata so the generated inspect-only maintenance override can attach exact credentials without copying them to mutating operations. - `scripts/generate-connector-secrets-override.sh`: operation-specific maintenance secrets. - Create `docker/workspace-maintenance-entrypoint.sh`; modify `docker/core.Dockerfile` with an explicit Linux native-compiler build stage, addon load smoke check, exact addon copy, and compiler-free runtime stage. - Retire/redirect fixture-only `deploy/compose.preprocess.yaml` as a non-public compatibility test path; do not leave two operator commands. **Shared Node operator** - Create `backend/src/workspaces/runtime-config-lease.ts`: shared snapshot read/render and deterministic protected config lease. - Modify `backend/src/tht/tht-runner.ts` to delegate session/operator rendering to the shared component without changing route behavior. - Create `backend/native/workspace-fs-at/workspace_fs_at.cc`, `backend/native/workspace-fs-at/binding.gyp`, `backend/src/native/workspace-fs-at-binding.d.ts`, `backend/src/workspaces/workspace-fs-at.ts`, and `backend/scripts/build-workspace-fs-at.mjs`: the repo-owned Node-API v8 FD-relative syscall seam and pinned build. - Create `backend/src/workspaces/workspace-lock-root-lease.ts`: the sole canonical input/factory and retained-FD root-lease implementation, plus the P2-owned opaque transferable shared `session-readers.lock` owner, consuming only the typed `workspace-fs-at` wrapper. - Create `backend/src/workspaces/preprocessing-state.ts`: state schema, durable writes, writer capability, the P2-owned callback-scoped exclusive `session-readers.lock` operation, inherited-FD verification contract, and resume reconciliation. Exact `fs-ext@2.1.1` remains imported only by `workspace-fs-at.ts` for typed kernel `flock`. - Modify `backend/src/workspaces/registry.ts`: durable addressed bootstrap/pull/author-publication planning and all-or-nothing active-state publication; remove direct activation paths. - Create `backend/src/workspaces/registry-publication.ts`: the single addressed request/job/export owner plus callback-scoped synchronizer/participant protocol; no lock reacquisition. - Modify `backend/src/routes/workspaces.ts` and `backend/src/app.ts`: status, pull, lazy list/bootstrap, and dependency wiring all use the same addressed owner. - Add tests: `backend/test/workspace-fs-at-native.test.ts`, `backend/test/workspace-lock-root-lease.test.ts`, `backend/test/workspace-session-readers-lock.test.ts`, `backend/test/workspace-registry-addressed-publication.test.ts`, `backend/test/workspace-registry-addressed-process.test.ts`, `backend/test/routes-workspaces.test.ts`, `backend/test/app.test.ts`, `backend/test/registry-pull-job-exports.test.ts`, `backend/test/fixtures/workspace-fs-at-race-worker.mjs`, `backend/test/fixtures/workspace-lock-root-worker.mjs`, `backend/test/fixtures/workspace-session-readers-worker.mjs`, and `backend/test/fixtures/workspace-registry-addressed-worker.mjs`. - Modify `backend/package.json` and `backend/package-lock.json`: pin `node-gyp@11.2.0`, exact `fs-ext@2.1.1` for `flock(2)` only, the Node 22 engine/build scripts, and the committed addon build inputs. - Create `backend/src/workspaces/preprocessing-service.ts`: closed stage coordinator and security preflights. - Create `backend/src/workspace-maintenance.ts`: compiled stdin/argv entrypoint and pristine result encoder. - Add tests: `backend/test/workspace-runtime-config-lease.test.ts`, `workspace-preprocessing-state.test.ts`, `workspace-preprocessing-service.test.ts`, `workspace-maintenance.test.ts`. **Harness machine contracts** - `harness/tht/cli/preprocess_cmd.py`: authoritative runtime workspace identity; require-existing collection mode; locked-FD verification before publishing operations. - `harness/tht/cli/schema_cmd.py`: extracted deterministic helpers, JSON suggest/check, safe SQL staging and annotation validation. - `harness/tht/cli/vector_cmd.py`: JSON schema-index result. - `harness/tht/config.py`, `harness/tht/adapters/factory.py`, `harness/tht/adapters/evidence/http.py`: exact rendered HTTP allowlist, adapter wiring, per-hop DNS/peer enforcement. - `backend/src/workspaces/runtime-renderer.ts`: render the same collection lifecycle and egress policy for session and maintenance configs. - `harness/tht/adapters/vector/qdrant.py`: explicit non-creating strict mode for every registry-rendered config. - Tests: `harness/tests/test_preprocess_cli.py`, `test_schema_fk_annotations.py`, `test_qdrant_cli_commands.py`, `test_registry_evidence_config.py`, `test_http_evidence_source.py`, plus new focused security cases. **Docs and gates** - Create `docs/contracts/workspace-preprocessing-cli.md`. - Update local/server installation manuals and `docs/testing/p2-p6-manual-verification.md` P2 only. - Create `scripts/p2-acceptance.sh`, `backend/scripts/p2-acceptance.mjs`, `backend/scripts/p2-acceptance.test.mjs`. - Create `scripts/p2-manual-acceptance.sh`, `backend/scripts/p2-manual-acceptance.mjs` only if needed to generate the isolated walkthrough lab; automation must never create PASS. - Update `PROJECT_STATE.md` only after implementation evidence exists. --- ## Frozen implementation interfaces These names are part of P2's handoff to P3–P6; implementation must not introduce a parallel operator, renderer, state store, or lock under different names. ```go // tools/thothctl/internal/workspaceops/operations.go type Command interface { workspaceCommand() } type InspectCommand struct { WorkspaceID string; JSON bool } // no bootstrap run-ID member const CodeRegistryBootstrapRecoveryConflict = "registry_bootstrap_recovery_conflict" func ParseWorkspaceCommand(args []string) (Command, error) func Run(ctx context.Context, installation config.Installation, runner compose.Runner, command Command, stdin io.Reader) (Result, error) // tools/thothctl/internal/compose/runner.go type CaptureLimits struct { StdoutBytes int64; StderrBytes int64 } func (r Runner) RunBounded(ctx context.Context, args []string, stdin io.Reader, limits CaptureLimits) (Result, error) // tools/thothctl/internal/safeio/files.go plus platform implementations func ReadCanonicalUTF8(path string, maximum int64) ([]byte, error) func WriteCanonicalExclusive(path string, contents []byte, mode fs.FileMode) error ``` `ParseWorkspaceCommand` is the only translator from host argv to the closed command union. `Run` may build only the fixed Compose invocation for the corresponding union member. It must not accept raw child argv. `workspaceops.Run` must call `RunBounded` with exactly 1,048,576 stdout bytes and 65,536 stderr bytes. `RunBounded` streams into fixed-capacity collectors and, on the first byte over either limit, cancels and terminates the complete owned process group before returning `compose.ErrOutputLimit`; it never buffers the overflow tail. Existing `Runner.Run` remains for unrelated callers but is forbidden at the workspace public boundary. `ReadCanonicalUTF8` reuses the existing component-by-component no-follow reader and adds UTF-8/identity rechecks; `WriteCanonicalExclusive` refuses an existing leaf, hardlink/reparse ambiguity, and parent replacement. ```ts // backend/src/native/workspace-fs-at-binding.d.ts -- the complete raw addon surface declare const nativeWorkspaceFsAtHandleBrand: unique symbol; declare const nativeWorkspaceFsAtComponentBrand: unique symbol; export interface NativeWorkspaceFsAtHandleV1 { readonly [nativeWorkspaceFsAtHandleBrand]: true; } export type NativeWorkspaceFsAtComponentV1 = string & { readonly [nativeWorkspaceFsAtComponentBrand]: true; }; export interface NativeWorkspaceFsAtStatV1 { readonly device: bigint; readonly inode: bigint; readonly mode: number; readonly uid: number; readonly gid: number; readonly nlink: bigint; } export interface NativeWorkspaceFsAtErrorV1 extends Error { readonly code: string; readonly errno: number; readonly syscall: "openat" | "mkdirat" | "fstat" | "fstatat" | "fsync" | "fcntl" | "close"; } export interface NativeWorkspaceFsAtOpenResultV1 { readonly handle: NativeWorkspaceFsAtHandleV1; readonly openedStat: NativeWorkspaceFsAtStatV1; } export interface WorkspaceFsAtBindingV1 { openat(input: { readonly parent: NativeWorkspaceFsAtHandleV1 | null; readonly name: "/" | NativeWorkspaceFsAtComponentV1; readonly kind: "directory" | "regular_lock"; readonly createMode: 0 | 0o600; }): NativeWorkspaceFsAtOpenResultV1; mkdirat(parent: NativeWorkspaceFsAtHandleV1, name: NativeWorkspaceFsAtComponentV1, mode: 0o700): void; fstatat(parent: NativeWorkspaceFsAtHandleV1, name: NativeWorkspaceFsAtComponentV1): NativeWorkspaceFsAtStatV1; fsyncDirectory(handle: NativeWorkspaceFsAtHandleV1): void; close(handle: NativeWorkspaceFsAtHandleV1): void; fdNumberForSynchronousBorrow(handle: NativeWorkspaceFsAtHandleV1): number; } // backend/src/workspaces/workspace-fs-at.ts -- the only importer of the .node binding import type { NativeWorkspaceFsAtStatV1 } from "../native/workspace-fs-at-binding.js"; export interface WorkspaceFsAtStatV1 extends NativeWorkspaceFsAtStatV1 {} export class OwnedWorkspaceFsAtDirectory { private constructor(); stat(): WorkspaceFsAtStatV1; close(): void; } export class OwnedWorkspaceFsAtRegularFile { private constructor(); stat(): WorkspaceFsAtStatV1; close(): void; } export type LockFileName = "writer.lock" | "session-readers.lock"; export type WorkspaceFlockKindV1 = "shared" | "exclusive"; export type WorkspaceFlockWaitV1 = "blocking" | "nonblocking"; export class WorkspaceFsAtV1 { openRoot(): OwnedWorkspaceFsAtDirectory; openDirectoryAt(parent: OwnedWorkspaceFsAtDirectory, component: string): OwnedWorkspaceFsAtDirectory; openOrCreateLockAt(parent: OwnedWorkspaceFsAtDirectory, component: LockFileName, mode: 0o600): OwnedWorkspaceFsAtRegularFile; mkdirAt(parent: OwnedWorkspaceFsAtDirectory, component: string, mode: 0o700): void; statAtNoFollow(parent: OwnedWorkspaceFsAtDirectory, component: string): WorkspaceFsAtStatV1; fsyncDirectory(directory: OwnedWorkspaceFsAtDirectory): void; flockOwnedLock(owned: OwnedWorkspaceFsAtRegularFile, kind: WorkspaceFlockKindV1, wait: WorkspaceFlockWaitV1): void; } // backend/src/workspaces/runtime-config-lease.ts export class WorkspaceRuntimeConfigLeaseFactory { acquireSession(snapshotPath: string): RuntimeConfigLease; acquireMaintenance(input: MaintenanceRuntimeInput): RuntimeConfigLease; } // backend/src/workspaces/workspace-lock-root-lease.ts declare const canonicalWorkspaceIdBrand: unique symbol; declare const revision40Brand: unique symbol; declare const sha256HexBrand: unique symbol; export type CanonicalWorkspaceId = string & { readonly [canonicalWorkspaceIdBrand]: true }; export type Revision40 = string & { readonly [revision40Brand]: true }; export type Sha256Hex = string & { readonly [sha256HexBrand]: true }; export interface WorkspaceLockRootIdentityV1 { readonly schemaVersion: 1; readonly workspaceId: CanonicalWorkspaceId; readonly device: bigint; readonly inode: bigint; } export class CanonicalWorkspaceLockRootInput { private constructor(); readonly workspaceId: CanonicalWorkspaceId; } export class BorrowedVerifiedWorkspaceLockRootLease { private constructor(); readonly identity: WorkspaceLockRootIdentityV1; } export class WorkspaceSessionReadersLockLease { private constructor(); readonly rootIdentity: WorkspaceLockRootIdentityV1; transfer(): WorkspaceSessionReadersLockLease; close(): Promise; } export class VerifiedWorkspaceLockRootLease { private constructor(); readonly identity: WorkspaceLockRootIdentityV1; borrow(action: (borrowed: BorrowedVerifiedWorkspaceLockRootLease) => Promise): Promise; acquireSessionReadersShared(): Promise; transfer(): VerifiedWorkspaceLockRootLease; close(): Promise; } export class VerifiedWorkspaceLockRootLeaseFactory { constructor(input: { readonly workspaceFsAt: WorkspaceFsAtV1; readonly installationId: string; readonly sessionsRootFromValidatedInstallationConfig: string; readonly serviceUid: number; readonly provisionedWorkspaceMode: 0o700; }); canonicalInput(workspaceId: string): CanonicalWorkspaceLockRootInput; acquire(input: CanonicalWorkspaceLockRootInput): Promise; acquireOrProvision( input: CanonicalWorkspaceLockRootInput, ): Promise; } // backend/src/workspaces/preprocessing-state.ts import type { RuntimeConfigLease } from "./runtime-config-lease.js"; import type { BorrowedVerifiedWorkspaceLockRootLease, CanonicalWorkspaceId, Revision40, VerifiedWorkspaceLockRootLease, WorkspaceLockRootIdentityV1, } from "./workspace-lock-root-lease.js"; export class PreprocessingStateStore { create(input: CreateRunInput): Promise; loadForResume(input: ResumeRunInput): Promise; transition(runId: string, transition: RunTransition): Promise; writeFkCandidate(runId: string, yaml: Uint8Array): Promise; recordFkReview(runId: string, review: FkReviewInput): Promise; } interface WorkspaceLockedChildRequestBase { readonly workspaceId: CanonicalWorkspaceId; readonly revision: Revision40; readonly rootIdentity: WorkspaceLockRootIdentityV1; readonly runtimeConfig: RuntimeConfigLease; readonly childRunId: string; } export interface DwhLockedChildRequest extends WorkspaceLockedChildRequestBase { readonly kind: "dwh_preprocess"; readonly stage: "introspect" | "lsh"; } export interface SchemaLockedChildRequest extends WorkspaceLockedChildRequestBase { readonly kind: "schema_preprocess"; readonly stage: "fk_suggest" | "fk_check" | "schema_index"; readonly reviewedArtifact: ArtifactIdentity | null; } export interface EvidenceLockedChildRequest extends WorkspaceLockedChildRequestBase { readonly kind: "evidence_preprocess"; readonly stage: "http_publish"; } export type WorkspaceLockedChildRequest = | DwhLockedChildRequest | SchemaLockedChildRequest | EvidenceLockedChildRequest; export interface WorkspaceLockedChildResult { exitCode: number; stdout: Uint8Array; stderr: Uint8Array; } export class BorrowedWorkspaceSessionReadersExclusiveLockLease { private constructor(); readonly workspaceId: CanonicalWorkspaceId; readonly rootIdentity: WorkspaceLockRootIdentityV1; assertLive(): void; } export class WorkspaceWriterLockCapability { private constructor(); readonly workspaceId: CanonicalWorkspaceId; readonly rootIdentity: WorkspaceLockRootIdentityV1; runUnderSessionReadersExclusive( action: (lease: BorrowedWorkspaceSessionReadersExclusiveLockLease) => Promise, ): Promise; spawnChild(request: WorkspaceLockedChildRequest): Promise; } export interface BorrowedOrderedWorkspaceWriterLeaseV1 { readonly workspaceId: CanonicalWorkspaceId; readonly rootLease: BorrowedVerifiedWorkspaceLockRootLease; readonly writerCapability: WorkspaceWriterLockCapability; } export class OrderedWorkspaceWriterCapabilitySet { private constructor(); readonly workspaceIds: readonly CanonicalWorkspaceId[]; forWorkspace( workspaceId: CanonicalWorkspaceId, action: (lease: BorrowedOrderedWorkspaceWriterLeaseV1) => Promise, ): Promise; forEachWorkspace( action: (lease: BorrowedOrderedWorkspaceWriterLeaseV1) => Promise, ): Promise; } export function runUnderOrderedWorkspaceWriterLocks( rootLeases: readonly VerifiedWorkspaceLockRootLease[], action: (capabilities: OrderedWorkspaceWriterCapabilitySet) => Promise, ): Promise; export function runUnderWorkspaceWriterLock( rootLease: VerifiedWorkspaceLockRootLease, action: (capability: WorkspaceWriterLockCapability) => Promise, ): Promise; export function probeWorkspaceWriterLock( rootLease: VerifiedWorkspaceLockRootLease, ): Promise<"available" | "held">; // backend/src/workspaces/registry-publication.ts import type { CanonicalWorkspaceId, Revision40, Sha256Hex, VerifiedWorkspaceLockRootLeaseFactory, } from "./workspace-lock-root-lease.js"; import type { BorrowedOrderedWorkspaceWriterLeaseV1, BorrowedWorkspaceSessionReadersExclusiveLockLease, OrderedWorkspaceWriterCapabilitySet, } from "./preprocessing-state.js"; declare const registryRunId32Brand: unique symbol; export type RegistryRunId32 = string & { readonly [registryRunId32Brand]: true }; export type RegistryAddressedOperationV1 = "registry_bootstrap" | "registry_pull"; export type RegistryAddressedPublicationPhaseV1 = | "request_claimed" | "target_advertised" | "target_fetched" | "planned" | "participants_prepared" | "publication_intent_durable" | "target_published" | "terminal_durable"; export type RegistryAddressedJobArtifactPathV1 = `addressed-publication-jobs/${RegistryRunId32}.json`; export interface RegistryWorkspaceManifestIdentityV1 { readonly workspaceId: CanonicalWorkspaceId; readonly revision: Revision40; readonly descriptorBlob: Revision40; readonly manifestSha256: Sha256Hex; } export interface RegistryActiveSnapshotV1 { readonly schemaVersion: 1; readonly commit: Revision40; readonly manifestSha256: Sha256Hex; readonly workspaces: readonly RegistryWorkspaceManifestIdentityV1[]; } interface RegistryAddressedPlanFieldsV1 { readonly schemaVersion: 1; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly remoteRefIdentitySha256: Sha256Hex; readonly jobArtifactPath: RegistryAddressedJobArtifactPathV1; readonly advertisedTargetCommit: Revision40; readonly immutableTargetRef: `refs/thoth/addressed-runs/${RegistryRunId32}/target`; readonly fetchedTargetCommit: Revision40; readonly targetCommit: Revision40; readonly targetManifestSha256: Sha256Hex; readonly targetWorkspaces: readonly RegistryWorkspaceManifestIdentityV1[]; readonly changedWorkspaceIds: readonly CanonicalWorkspaceId[]; readonly changedSetSha256: Sha256Hex; } export interface RegistryBootstrapAddressedPlanV1 extends RegistryAddressedPlanFieldsV1 { readonly operation: "registry_bootstrap"; readonly changedSetRule: "all_target_workspace_ids"; readonly baseCommit: null; readonly baseManifestSha256: null; readonly baseWorkspaces: readonly []; } export interface RegistryPullAddressedPlanV1 extends RegistryAddressedPlanFieldsV1 { readonly operation: "registry_pull"; readonly changedSetRule: "symmetric_base_target_workspace_difference"; readonly baseCommit: Revision40; readonly baseManifestSha256: Sha256Hex; readonly baseWorkspaces: readonly RegistryWorkspaceManifestIdentityV1[]; } export type RegistryAddressedPlanV1 = | RegistryBootstrapAddressedPlanV1 | RegistryPullAddressedPlanV1; interface RegistryAddressedPublicationStateFieldsV1 { readonly schemaVersion: 1; readonly runId: RegistryRunId32; readonly requestSha256: Sha256Hex; readonly jobArtifactPath: RegistryAddressedJobArtifactPathV1; readonly phase: RegistryAddressedPublicationPhaseV1; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly remoteRefIdentitySha256: Sha256Hex; readonly advertisedTargetCommit: Revision40 | null; readonly immutableTargetRef: `refs/thoth/addressed-runs/${RegistryRunId32}/target` | null; readonly fetchedTargetCommit: Revision40 | null; readonly targetCommit: Revision40 | null; readonly targetManifestSha256: Sha256Hex | null; readonly targetWorkspaces: readonly RegistryWorkspaceManifestIdentityV1[] | null; readonly changedWorkspaceIds: readonly CanonicalWorkspaceId[] | null; readonly planSha256: Sha256Hex | null; readonly changedSetSha256: Sha256Hex | null; readonly participantsSha256: Sha256Hex | null; readonly synchronizersSha256: Sha256Hex | null; readonly publicationIntentSha256: Sha256Hex | null; readonly publishedActiveStateSha256: Sha256Hex | null; readonly terminalResultSha256: Sha256Hex | null; readonly priorStateSha256: Sha256Hex | null; } export interface RegistryBootstrapAddressedPublicationStateV1 extends RegistryAddressedPublicationStateFieldsV1 { readonly operation: "registry_bootstrap"; readonly baseCommit: null; readonly baseManifestSha256: null; readonly baseWorkspaces: readonly []; readonly changedSetRule: "all_target_workspace_ids" | null; } export interface RegistryPullAddressedPublicationStateV1 extends RegistryAddressedPublicationStateFieldsV1 { readonly operation: "registry_pull"; readonly baseCommit: Revision40; readonly baseManifestSha256: Sha256Hex; readonly baseWorkspaces: readonly RegistryWorkspaceManifestIdentityV1[]; readonly changedSetRule: "symmetric_base_target_workspace_difference" | null; } export type RegistryAddressedPublicationStateV1 = | RegistryBootstrapAddressedPublicationStateV1 | RegistryPullAddressedPublicationStateV1; export class BorrowedWorkspaceMaintenanceQuiescenceLease { private constructor(); readonly workspaceId: CanonicalWorkspaceId; } export interface AddressedWorkspacePublicationLeaseV1 extends BorrowedOrderedWorkspaceWriterLeaseV1 { readonly quiescence: BorrowedWorkspaceMaintenanceQuiescenceLease; readonly readers: BorrowedWorkspaceSessionReadersExclusiveLockLease; } export interface CapabilityAwareRegistryPublicationParticipant { readonly participantId: string; prepare( plan: RegistryAddressedPlanV1, workspace: AddressedWorkspacePublicationLeaseV1, ): Promise; reconcile( plan: RegistryAddressedPlanV1, workspace: AddressedWorkspacePublicationLeaseV1, prepared: T, phase: RegistryAddressedPublicationPhaseV1, ): Promise; } export interface RegistrySynchronizerPreparedV1 { readonly synchronizerId: string; readonly preparedSha256: Sha256Hex; } export interface CapabilityAwareRegistryPublicationSynchronizer { readonly synchronizerId: string; ensureForPublication( plan: RegistryAddressedPlanV1, capabilities: OrderedWorkspaceWriterCapabilitySet, phase: "planned" | "participants_prepared" | "publication_intent_durable" | "target_published", ): Promise; } export class CapabilityAwareRegistryPublicationLifecycleOwner { constructor(); run(input: { readonly plan: RegistryAddressedPlanV1; readonly capabilities: OrderedWorkspaceWriterCapabilitySet; readonly participants: readonly CapabilityAwareRegistryPublicationParticipant[]; readonly synchronizers: readonly CapabilityAwareRegistryPublicationSynchronizer[]; readonly action: () => Promise; }): Promise; } export type RegistryAddressedRequestV1 = | { readonly mode: "create"; readonly operation: "registry_bootstrap"; readonly runId: RegistryRunId32; readonly requestSha256: Sha256Hex; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly expectedBaseCommit: null; readonly remoteRefIdentitySha256: Sha256Hex; } | { readonly mode: "resume"; readonly operation: "registry_bootstrap"; readonly runId: RegistryRunId32; readonly requestSha256: Sha256Hex; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly remoteRefIdentitySha256: Sha256Hex; } | { readonly mode: "create"; readonly operation: "registry_pull"; readonly runId: RegistryRunId32; readonly requestSha256: Sha256Hex; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly expectedBaseCommit: Revision40; readonly remoteRefIdentitySha256: Sha256Hex; } | { readonly mode: "resume"; readonly operation: "registry_pull"; readonly runId: RegistryRunId32; readonly requestSha256: Sha256Hex; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly remoteRefIdentitySha256: Sha256Hex; }; export interface RegistryBootstrapAddressedResultV1 { readonly operation: "registry_bootstrap"; readonly runId: RegistryRunId32; readonly jobArtifactPath: RegistryAddressedJobArtifactPathV1; readonly plan: RegistryBootstrapAddressedPlanV1; readonly planSha256: Sha256Hex; readonly phase: "terminal_durable"; readonly publication: "target" | "reconciled_target" | "unchanged"; } export interface RegistryPullAddressedResultV1 { readonly operation: "registry_pull"; readonly runId: RegistryRunId32; readonly jobArtifactPath: RegistryAddressedJobArtifactPathV1; readonly plan: RegistryPullAddressedPlanV1; readonly planSha256: Sha256Hex; readonly phase: "terminal_durable"; readonly publication: "target" | "reconciled_target" | "unchanged"; } export type RegistryAddressedResultV1 = | RegistryBootstrapAddressedResultV1 | RegistryPullAddressedResultV1; export interface RegistryBootstrapRecoveryIdentityV1 { readonly operation: "registry_bootstrap"; readonly requestSha256: Sha256Hex; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly remoteRefIdentitySha256: Sha256Hex; } export type RegistryEnsureBootstrapAddressedResultV1 = | { readonly kind: "already_active"; readonly snapshot: RegistryActiveSnapshotV1; } | { readonly kind: "bootstrap_terminal"; readonly snapshot: RegistryActiveSnapshotV1; readonly result: RegistryBootstrapAddressedResultV1; }; export interface RegistryBootstrapRecoveryScanLimitsV1 { readonly maximumDirectoryEntries: 4096; readonly maximumArtifactBytes: 1048576; readonly maximumTotalArtifactBytes: 67108864; } // backend/src/workspaces/registry.ts import type { VerifiedWorkspaceLockRootLeaseFactory } from "./workspace-lock-root-lease.js"; import type { CapabilityAwareRegistryPublicationLifecycleOwner, CapabilityAwareRegistryPublicationParticipant, CapabilityAwareRegistryPublicationSynchronizer, RegistryAddressedRequestV1, RegistryAddressedResultV1, RegistryEnsureBootstrapAddressedResultV1, RegistryBootstrapRecoveryIdentityV1, } from "./registry-publication.js"; export class WorkspaceRegistry { constructor(input: { readonly rootLeaseFactory: VerifiedWorkspaceLockRootLeaseFactory; readonly lifecycleOwner: CapabilityAwareRegistryPublicationLifecycleOwner; readonly participants: readonly CapabilityAwareRegistryPublicationParticipant[]; readonly synchronizers: readonly CapabilityAwareRegistryPublicationSynchronizer[]; }); ensureBootstrapAddressed( identity: RegistryBootstrapRecoveryIdentityV1, ): Promise; publishAddressed(request: RegistryAddressedRequestV1): Promise; } // backend/src/workspaces/preprocessing-service.ts export class WorkspacePreprocessingService { execute(command: MaintenanceCommand, ingress: MaintenanceIngress): Promise; } // backend/src/workspace-maintenance.ts export async function main( argv: readonly string[], stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream, ): Promise; ``` `workspace_fs_at.cc` uses only the stable C Node-API at pinned `NAPI_VERSION=8`; `binding.gyp` compiles it as C++17 on Node 22 and has an explicit `OS=="linux" or OS=="mac"` condition, with every other target rejected. `build-workspace-fs-at.mjs` rejects a non-22 Node, requires `process.versions.napi >= 8`, locates already-installed Node 22 headers (or an explicit `npm_config_nodedir`), invokes exact lockfile `node-gyp@11.2.0`, and verifies that the sole output is `native/workspace-fs-at/build/Release/workspace_fs_at.node`; it never downloads headers or a binary. `package.json` defines `build:native` as this driver, `build:ts` as `tsc -p tsconfig.json`, `build` as `npm run build:native && npm run build:ts`, and `test:native` as the driver followed by the native Vitest file. The wrapper is the only `.node` importer and checks the frozen export names at load. The binding owns every returned descriptor in a native handle with `FD_CLOEXEC`; each successful `openat` immediately `fstat`s the opened description and atomically returns its immutable handle plus that stat identity, closing on any conversion failure. `openat` admits a null parent if and only if opening root `/` as a directory; otherwise it requires an owned directory parent and one validated single canonical component. It admits only kind `directory` (fixed `O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC`) or `regular_lock` (fixed `O_RDWR|O_CREAT|O_NOFOLLOW|O_CLOEXEC`, mode `0600`); the wrapper further restricts regular-lock names to exact `LockFileName = "writer.lock" | "session-readers.lock"` and requires literal mode `0o600`. `mkdirat` admits only a single canonical component and mode `0700`; `fstatat` always uses `AT_SYMLINK_NOFOLLOW`; directory fsync checks the handle type; and `close` consumes it exactly once. The wrapper rejects empty, `.`, `..`, slash-containing, NUL, non-UTF-8, over-255-byte, wrong-kind, cross-addon, closed, and concurrent-close inputs before a syscall, and does not export the raw binding, an FD accessor, or the numeric-borrow callback. A synchronous non-reentrant numeric borrow exists only inside the wrapper module and only `flockOwnedLock` and `duplicateForChildStdio` may invoke it; close cannot run during either borrow. `flockOwnedLock` accepts only an owned regular-lock handle plus typed `"shared" | "exclusive"` and `"blocking" | "nonblocking"`, maps those four combinations exactly to `fs-ext.flockSync(fd, "sh" | "ex" | "shnb" | "exnb")`, and never exposes `fd`. The child duplication helper likewise exposes no number and only installs the owned writer/root descriptions into the fixed child FD 3/4 slots. Native finalization is leak containment only, never a successful durability path. All syscalls except `close` retry `EINTR`. Native errors are ordinary `Error` objects with stable `code`, numeric `errno`, and allowlisted `syscall`, with no supplied path text; the wrapper maps expected filesystem races to the frozen conflict code without hiding the errno class. `EEXIST` is returned to the factory for the explicit winning-creator branch. Close marks ownership consumed before calling the kernel and is never retried; an `EINTR`/uncertain close becomes `ERR_WORKSPACE_FS_AT_CLOSE_UNCERTAIN` and fails the outer operation so a recycled descriptor cannot be closed. Double close/borrow-after-close is `ERR_WORKSPACE_FS_AT_HANDLE_CLOSED`; wrong handle/type is `ERR_WORKSPACE_FS_AT_ARGUMENT`. Linux uses literal `openat(2)`, `mkdirat(2)`, `fstatat(2)`, and `fsync(2)`. Darwin uses the same POSIX calls and flags; directory durability first calls `fsync`, then only for `EINVAL`/`ENOTSUP` calls `fcntl(F_FULLFSYNC)`. If both are unsupported it returns stable `ERR_WORKSPACE_FS_AT_DIRECTORY_FSYNC_UNSUPPORTED` and the root factory fails closed—there is no fake success. Linux and Darwin native tests run against the host temporary filesystem and assert either successful durable directory sync or that exact Darwin fail-closed result; the Linux container gate requires success. Windows neither builds nor loads this backend addon; the supported Windows Go CLI continues to target the Linux core image. `docker/core.Dockerfile` names the compiler stage exactly `backend-native-build`: from the pinned Node 22 Bookworm digest it installs only `python3`, `make`, and `g++`, runs `npm ci`, `npm run build:native`, and the Linux native test. The following `backend-build` stage starts again from the same clean pinned Node image, copies the locked `node_modules` and exact `.node` output from `backend-native-build`, invokes no native compiler and runs `npm run build:ts`, then `npm prune --omit=dev --ignore-scripts` so node-gyp and build-only packages are absent while the already-built exact `fs-ext` binding remains. The final Python-slim `runtime` copies `dist`, the pruned runtime `node_modules`, and only `native/workspace-fs-at/build/Release/workspace_fs_at.node` at that same relative path; it does not copy node-gyp, headers, source, make, g++, or compiler-stage caches. Image build checks Node major 22, N-API >=8, loads both `workspace_fs_at.node` and `fs-ext`, and invokes one temp-directory Linux fsync smoke test as UID 10001 before accepting the image. `WorkspaceRuntimeConfigLeaseFactory` is the sole reader/binder/renderer handoff for both `ThtRunner.acquireWorkspaceRuntime` and maintenance. `VerifiedWorkspaceLockRootLeaseFactory` is the sole root producer and receives the sole `WorkspaceFsAtV1`. Its constructor no-follow opens and retains the installation sessions-parent FD; `canonicalInput` accepts a workspace ID, not a path, and returns a private-constructor value carrying factory provenance. `acquire` opens an existing canonical leaf. `acquireOrProvision` is the only missing-leaf path and is callable by registry publication only while `repository.lock` is held: under one factory-private parent provisioning lock it performs anchored `mkdirat(parentFd, workspaceId, 0700)`, opens with `O_DIRECTORY|O_NOFOLLOW`, requires UID exactly equal to `serviceUid` and mode exactly `0700`, fsyncs the created leaf and retained parent, and rechecks parent plus leaf device/inode before returning. `EEXIST` means a concurrent creator won and is accepted only after the same open/owner/mode/identity checks. Symlink/non-directory/wrong UID or mode, parent replacement, or leaf substitution fails closed. A provisioned unused leaf is retained on every later failure, including failure before publication; retry reverifies and reuses it. Both absent added IDs and absent never-used removed IDs follow this rule. The public root identity is diagnostic data only and cannot reopen the FD. `borrow` is callback-scoped and cannot outlive the owning handle. `transfer` requires zero active borrows, atomically invalidates the source, and returns the only new owner. `close` is idempotent, waits for current borrows, then closes the FD; borrow/transfer after close or transfer fails `preprocessing_conflict`. Path-to-retained-inode checks run before and after every relative open. `acquireSessionReadersShared()` is the one additional consuming operation: with no caller-selected operand it uses the retained root to open the literal `session-readers.lock` at `0600`, acquire typed shared/nonblocking flock, and on success move root plus lock ownership into `WorkspaceSessionReadersLockLease`. That lease alone may transfer into the Pi session owner; it remains live through child/stream teardown and closes lock then root exactly once. `runUnderOrderedWorkspaceWriterLocks(rootLeases, action)` is the sole producer of an ordered capability set and the sole constructor path for `WorkspaceWriterLockCapability`. It consumes all root leases, rejects duplicate workspace identities, sorts a private copy by canonical workspace ID, opens each relative `writer.lock` with `openOrCreateLockAt(..., 0o600)` and invokes `flockOwnedLock(owned, "exclusive", "nonblocking")` in lexical order without calling or nesting the single-lock public API, and enters `action(set)` only after all roots and writer locks are held. The set, every borrowed workspace lease, and every capability are valid only during that callback. On success, throw, cancellation, or partial acquisition it invalidates them first and closes writer FDs and owned root leases in exact reverse lexical order. `runUnderWorkspaceWriterLock` is only the one-element adapter over this function; it adds no producer or acquisition path. `probeWorkspaceWriterLock` likewise consumes and closes its lease. Each `WorkspaceWriterLockCapability` owns (rather than merely borrows) its transferred retained root lease for its whole lifetime. Its `runUnderSessionReadersExclusive(action)` method is the only consumer of that retained root for the reader gate: it opens the literal `session-readers.lock` at `0600`, takes typed exclusive/nonblocking flock, supplies only a callback-scoped `BorrowedWorkspaceSessionReadersExclusiveLockLease`, and invalidates then closes that lock when the action settles. `spawnChild` accepts only the frozen discriminated union, checks request workspace ID, revision-bound runtime-config lease, and root device/inode against the capability, and rejects post-settlement/concurrent/cross-workspace use. It passes the actual locked writer open file description as FD 3 and the same retained root directory open file description as FD 4 to **every** mutating harness child. Python first requires both descriptors, verifies FD 4 is the expected owned root directory, opens `preprocessing/writer.lock` relative to FD 4 with no-follow semantics, proves that file's device/inode equals FD 3, verifies FD 3 is a single-link `0600` service-owned regular file, and requires `flock(3, LOCK_EX|LOCK_NB)` to report already held before any mutation. Missing, closed, renumbered, substituted, cross-root, or independently locked FD 3/4 fails `preprocessing_conflict`. No environment marker is authorization. P3/P5/P6 may add exact variants only to this same `WorkspaceLockedChildRequest` alias; they may not add another capability, raw argv/path/stdio member, ambient lookup, direct process spawn, or general escape hatch. The registry-root-relative durable artifact is exactly `addressed-publication-jobs/<32-hex-run-id>.json`; it is created with `0600` sibling-write/file-fsync/ rename/parent-fsync semantics. `WorkspaceRegistry.publishAddressed` accepts the exact `registry_bootstrap`/`registry_pull` request union above. Bootstrap requires no active base, persists `baseCommit`/`baseManifestSha256` as `null` and `baseWorkspaces` as `[]`, and defines `changedWorkspaceIds` as every target workspace ID in lexical order. Pull requires the request's exact active base and uses the lexical symmetric difference of canonical base/target workspace identities. Pull and author publication call `publishAddressed`; inspect, lazy list bootstrap, and status call only `ensureBootstrapAddressed`. Both enter one private addressed executor under the same repository-lock ownership; the old public/internal `bootstrap`, `pull`, `activate`, and direct active-pointer writer are removed, not retained as alternate publication paths. `ensureBootstrapAddressed(identity)` acquires `repository.lock` before its authoritative inspection and retains it without a gap through selection, claim/resume, all network work, publication, and terminal durability. Its first action under that lock is to reread and strictly validate the active pointer and referenced immutable snapshot through the same active-state reader used by ordinary registry reads. A valid active state returns `{ kind: "already_active", snapshot }` immediately, before opening or scanning the jobs directory and without network; any caller-side absence check is only an optimization. A present but malformed, incompatible, missing-target, identity-mismatched, or otherwise corrupt active state fails closed with `registry_bootstrap_recovery_conflict` and neither scans jobs nor bootstraps. Only a strictly proven absent pointer proceeds. It then opens exactly the registry-root-relative `addressed-publication-jobs/` directory no-follow and reads one lexically sorted initial snapshot capped at 4,096 directory entries. Durable writers use only final `[0-9a-f]{32}\.json` and deterministic sibling `.[0-9a-f]{32}.json.tmp` names. Before candidate selection, a sibling is unlinked and the directory fsynced only after proving it is a single-link service-owned `0600` regular file and that its encoded run ID has at most that one final; this is the exact pre-claim/interrupted-transition cleanup, not a candidate. A malformed/cross-owned/hardlinked or duplicate sibling fails closed. Final files must be single-link service-owned `0600` regular files; each is capped at 1,048,576 bytes and total final bytes read at 67,108,864, with identity rechecks after every read. After deterministic sibling cleanup and final-file reads, it performs one second bounded sorted enumeration and requires byte-identical final names and identities to the expected finals. Overflow, entry churn between enumeration/recheck, any other entry name/type, invalid JSON/schema/hash/phase, filename/run-ID disagreement, or duplicate run ID returns stable `registry_bootstrap_recovery_conflict`; it never truncates, skips, quarantines, or guesses. The selector validates every record first, then ignores valid `terminal_durable` records for automatic nonterminal selection. A direct exact same-ID addressed resume may replay such a terminal record only when its stored operation, request hash, installation identity, repository identity, and remote-ref identity all equal the caller's exact identity; replay is stored-result-only and performs no network. For automatic empty-registry recovery: zero nonterminal addressed records creates one fresh 32-hex ID and its exact create request; exactly one nonterminal record resumes that exact run ID before any network only if it is `registry_bootstrap` and all five operation/request/installation/repository/remote identity fields match; multiple nonterminal records, a nonterminal pull, or any identity mismatch fails with `registry_bootstrap_recovery_conflict`. Terminal records are never ranked against nonterminals. Successful create/resume strictly rereads the published active pointer/snapshot while the lock remains held and returns `{ kind: "bootstrap_terminal", result, snapshot }`; that snapshot has the same commit, manifest digest, and ordered workspace identities as the durable result plan. Thus two or more callers that all observed absence before queueing converge: the first publishes once, while each later lock holder takes `already_active` before a job scan, terminal ranking, run creation, or network, and every caller consumes the exact same validated active snapshot. There is no mtime, lexical-last, newest, advertised-OID, or remote-head heuristic. The bootstrap `requestSha256` is the canonical digest of schema version, operation, installation identity, repository identity, and remote-ref identity—never the random run ID or mode—so create and resume compare the same request. The installation identity is derived once from the validated installation descriptor and registry volume identity; repository and remote-ref identities use the same canonical functions as addressed publication. Every create state and plan persists all three identity digests, and resume requires byte-equality before reconciliation. With `repository.lock` held, create first writes and parent-fsyncs phase `request_claimed`, including the operation, request hash, installation/repository/remote-ref identities, artifact path, and the complete base fields, **before any `ls-remote`, fetch, or other network call**. Advertisement then reads the remote ref once and durably records its 40-hex OID as `advertisedTargetCommit` at phase `target_advertised`. Only afterward may exact-OID fetch write `refs/thoth/addressed-runs//target`; that ref is create-only and immutable for the run. The implementation verifies the ref resolves byte-for-byte to the advertised OID and durably records the same OID as `fetchedTargetCommit` at phase `target_fetched` before reading the target manifest and persisting the full base/target/changed plan at `planned`. `planSha256` covers the operation, installation/repository/remote-ref identities, artifact path, base fields, advertised OID, immutable-ref identity, fetched/target fields, and changed set. Pre-pin recovery is explicit. Before `request_claimed` is durable no network was allowed and a repeated same request may recreate the claim. From durable `request_claimed` until `target_advertised` is durable, no target has been promised: same-ID resume repeats advertisement and may observe a newer OID. Once `target_advertised` is durable, that OID is permanent. Death before/during its exact-OID fetch retries only that OID if the immutable ref is absent; if the ref already exists at that OID, resume performs no network and advances to `target_fetched`; any different ref OID is terminal corruption. Death after `target_fetched` never fetches or re-advertises. A terminal job always replays its stored result even if a later independent pull moved remote-tracking refs; drift checks apply only while reconciling a nonterminal job. Kill tests cover before claim rename, after claim fsync, during/after advertisement but before its durable pin, after advertisement fsync, during/after exact fetch, after immutable-ref creation but before `target_fetched`, after its fsync, and every later publication phase. After `planned`, the registry acquires/provisions one root lease per complete changed ID, passes all of them once to `runUnderOrderedWorkspaceWriterLocks`, and performs quiescence, exclusive-reader gates, participant preparation/reconciliation, synchronizers, pointer publication, and terminal persistence inside that one callback and the same production `CapabilityAwareRegistryPublicationLifecycleOwner`. For each already-ordered capability the owner calls only `runUnderSessionReadersExclusive`; it never opens/flocks a reader file itself, and `AddressedWorkspacePublicationLeaseV1.readers` is that method's exact `BorrowedWorkspaceSessionReadersExclusiveLockLease`. Participants receive only callback-borrowed addressed leases, synchronizers receive the same callback-scoped opaque set, and neither can reacquire locks. Any failure invalidates borrows and closes readers/quiescence and then writers/roots in reverse lexical order, leaving active state at exact base (or absent for bootstrap) before repository-lock release. A compile/runtime test uses the production lifecycle owner—not a fake—to hold A and B simultaneously and proves neither capability survives callback settlement. All participant `prepare` calls and durable digests complete before `publication_intent_durable`. Publication stages immutable target workspace records, then performs one sibling write + file fsync + atomic rename of the installation-wide active-snapshot pointer + parent fsync. Readers resolve only the pointer, so staged or crash-left projection records are never a partial active publication. A synchronous no-follow reread of pointer and target snapshot must be byte-equal before phase `target_published`. Before pointer rename, active remains exact base/absent; at or after the boundary, reconcile all-base/all-target/mixed exact projections deterministically to target and fail closed on a third identity. `terminal_durable` is written before the ordered callback settles. `WorkspacePreprocessingService.execute` remains the ordinary single-workspace coordinator. `main` parses and encodes one result only; it never starts Fastify. The harness keeps Typer wrappers thin over these testable helpers: ```python # harness/tht/cli/schema_cmd.py def suggest_fks(config: Path, *, sql_roots: tuple[Path, ...], assumptions: tuple[str, ...]) -> dict: ... def check_annotations(config: Path, *, annotations: Path | None = None) -> dict: ... # harness/tht/cli/vector_cmd.py def index_schema(config: Path) -> dict: ... # harness/tht/cli/preprocess_cmd.py # run_from_config and run_dwh_from_config remain the engine entrypoints, but both derive # workspace identity through workspace_id_for_config(cfg, config). ``` Host ingress is one bounded schema-v1 JSON document on Compose stdin. SQL/annotation bytes are base64 fields with a declared SHA-256 and logical basename; Node verifies both before exclusive staging under the owned run root. Candidate export is an internal bounded `hostExport: {mediaType, sha256, contentBase64}` field emitted only by `schema suggest-fks`. Candidate YAML is capped at exactly 700 KiB (716,800 decoded bytes); its base64 plus the complete schema-v1 envelope must remain at or below the 1 MiB stdout limit, otherwise the operation fails before host output publication. `workspaceops.Run` verifies workspace/revision/run/digest, writes `--output` exclusively, removes `hostExport`, and only then re-encodes the public result. Raw SQL, annotation bytes, and base64 are never public JSON, human output, state JSON, argv, Compose config, or retained logs. --- ### Task 1: Freeze the native CLI, file-ingress, and result contracts **Files:** - Modify: `tools/thothctl/cmd/thothctl/main.go` - Modify: `tools/thothctl/cmd/thothctl/main_test.go` - Modify: `tools/thothctl/internal/safeio/files.go` - Modify platform-specific safe-I/O tests - Create: `tools/thothctl/internal/workspaceops/operations.go` - Create: `tools/thothctl/internal/workspaceops/operations_test.go` - Modify: `tools/thothctl/internal/compose/runner.go` - Create/modify: `tools/thothctl/internal/compose/runner_test.go` and platform process-group helpers/tests - Create: `docs/contracts/workspace-preprocessing-cli.md` - [ ] **Step 1: Write RED parser-table tests** for every valid command above and for duplicate/missing/unknown flags, invalid IDs, incompatible annotation flags, option-count/size limits, and passthrough/shell attempts. Assert inspect rejects `--resume`, `--bootstrap-run-id`, and any equivalent spelling; automatic bootstrap recovery is never host-selected. - [ ] **Step 2: Run** `cd tools/thothctl && go test ./cmd/thothctl ./internal/workspaceops -run 'Workspace|workspace' -v` and verify the new tests fail because `workspace` is unknown. - [ ] **Step 3: Add closed request types** (`InspectCommand` exactly as frozen above, `DwhRequest`, `SuggestFksRequest`, `CheckSchemaRequest`, `IndexSchemaRequest`, `EvidenceRequest`, `RunRequest`) and a parser that cannot represent arbitrary argv or a bootstrap run ID. - [ ] **Step 4: Write RED safe-I/O tests** for symlinks, hardlinks, directory input, replacement during read, Windows reparse points, existing output, >1 MiB SQL, >16 MiB total, and non-UTF-8 annotation input. - [ ] **Step 5: Implement bounded reads and exclusive restrictive output** using existing platform seams; return only generic file errors. - [ ] **Step 6: Write RED bounded-runner tests** for exactly-at-limit output, one-byte-over stdout/stderr, infinite stdout, infinite stderr, context cancellation, and a grandchild that keeps writing. Assert fixed-capacity capture, immediate owned process-group termination, and `compose.ErrOutputLimit`; no test may retain more than the configured limit. - [ ] **Step 7: Implement `Runner.RunBounded`** as the frozen streaming API above and require `workspaceops.Run` to use it. Add schema-v1 stdin/result validation with exact field allowlists, the exact 700 KiB candidate export cap, and complete-envelope bound. - [ ] **Step 8: Run GREEN native tests:** ```bash cd tools/thothctl gofmt -w cmd/thothctl/main.go cmd/thothctl/main_test.go \ internal/workspaceops/operations.go internal/workspaceops/operations_test.go \ internal/compose/*.go internal/safeio/*.go go test ./cmd/thothctl ./internal/workspaceops ./internal/safeio ./internal/compose -run 'Workspace|workspace|Canonical|Exclusive|Bounded|OutputLimit' -v go test ./... ``` Expected: focused and complete Go suites PASS; unsafe-file cases return usage exit 2 and never invoke the fake Compose runner. - [ ] **Step 9: Commit:** `feat: define P2 host workspace command contract`. ### Task 2: Add pristine harness JSON interfaces without changing the engine **Files:** - Modify: `harness/tht/cli/schema_cmd.py` - Modify: `harness/tht/cli/vector_cmd.py` - Modify: `harness/tht/cli/preprocess_cmd.py` - Modify: `harness/tests/test_schema_fk_annotations.py` - Modify: `harness/tests/test_qdrant_cli_commands.py` - Modify: `harness/tests/test_preprocess_cli.py` - [ ] **Step 1: Write RED tests** requiring `schema suggest-fks --json`, `schema check --json`, and `vector index-schema --json` to emit exactly one JSON object on stdout for success and failure, with no color/prose contamination. - [ ] **Step 2: Write RED deterministic FK tests** for bounded staged SQL files, stable candidate ordering, candidate SHA-256, annotation import, orphan counts, and no implicit review/write. - [ ] **Step 3: Write RED Evidence identity test** showing a config named `/dev/fd/3` still uses `runtime_identity.workspace_id`, never the config basename. - [ ] **Step 4: Run:** ```bash cd harness .venv/bin/pytest -q \ tests/test_schema_fk_annotations.py \ tests/test_qdrant_cli_commands.py \ tests/test_preprocess_cli.py ``` Expected: FAIL only on the new machine-contract assertions. - [ ] **Step 5: Extract pure helpers** returning typed dictionaries/models; keep existing human commands as renderers over the same helpers. - [ ] **Step 6: Implement the JSON flags and authoritative workspace identity**. Catch expected exceptions and emit stable safe codes; never serialize arbitrary exception text. - [ ] **Step 7: Run GREEN harness gates:** ```bash cd harness .venv/bin/pytest -q \ tests/test_schema_fk_annotations.py \ tests/test_qdrant_cli_commands.py \ tests/test_preprocess_cli.py .venv/bin/ruff check \ tht/cli/schema_cmd.py tht/cli/vector_cmd.py tht/cli/preprocess_cmd.py \ tests/test_schema_fk_annotations.py tests/test_qdrant_cli_commands.py tests/test_preprocess_cli.py ``` Expected: all three pytest files PASS and Ruff exits 0; captured JSON stdout parses as exactly one document in every new case. - [ ] **Step 8: Commit:** `feat: add P2 harness machine contracts`. ### Task 3: Add a non-creating semantic writer mode **Files:** - Modify: `harness/tht/config.py` - Modify: `harness/tht/adapters/factory.py` - Modify: `harness/tht/adapters/vector/qdrant.py` - Modify: `harness/tests/test_qdrant_vector_store.py` - Modify: `harness/tests/test_qdrant_cli_commands.py` - Modify: `harness/tests/test_registry_evidence_config.py` - [ ] **Step 1: Write RED tests** proving every registry-rendered config (session and maintenance) refuses a missing collection without issuing create/index mutations, refuses wrong dimensions/distance/index type, and still writes to an existing compatible collection. Legacy non-registry fixture configs retain their old default. - [ ] **Step 2: Run the focused RED tests:** ```bash cd harness .venv/bin/pytest -q \ tests/test_qdrant_vector_store.py \ tests/test_qdrant_cli_commands.py \ tests/test_registry_evidence_config.py \ -k 'require_existing or missing_collection or incompatible_collection' ``` Expected: FAIL because current `_ensure_collection(strict=True)` issues collection creation instead of returning `semantic_index_incompatible`. No unrelated test may fail. - [ ] **Step 3: Add the exact internal field** `vectors.collection_lifecycle: require_existing`; it is not a descriptor option. `renderRuntimeConfig` emits it for *all* schema-v3 registry configs, whether acquired by a session or maintenance. Only configs without registry `runtime_identity` retain the legacy create-capable default. - [ ] **Step 4: Thread the mode through the factory/store** and perform a read-only exact collection/index preflight before any upsert. Add a schema-v1 binding regression proving equal session/maintenance rendered configs produce equal `config_dwh_binding()` values. - [ ] **Step 5: Add a race regression**: delete the collection after preflight and prove the write fails rather than recreates it. - [ ] **Step 6: Run focused GREEN gates:** ```bash cd harness .venv/bin/pytest -q \ tests/test_qdrant_vector_store.py \ tests/test_qdrant_cli_commands.py \ tests/test_registry_evidence_config.py .venv/bin/ruff check \ tht/config.py tht/adapters/factory.py tht/adapters/vector/qdrant.py \ tests/test_qdrant_vector_store.py tests/test_qdrant_cli_commands.py \ tests/test_registry_evidence_config.py ``` Expected: all selected pytest tests PASS and Ruff exits 0. - [ ] **Step 7: Commit:** `fix: prevent P2 from owning Qdrant lifecycle`. ### Task 4: Extract the shared runtime configuration lease **Files:** - Create: `backend/src/workspaces/runtime-config-lease.ts` - Create: `backend/test/workspace-runtime-config-lease.test.ts` - Modify: `backend/src/tht/tht-runner.ts` - Modify: `backend/test/tht-runner.test.ts` - Modify: `backend/test/workspace-runtime-handoff.test.ts` - [ ] **Step 1: Write RED equivalence tests** feeding the same immutable snapshot, env, roots, installation overlay (including the normalized private-host allowlist), and semantic contract to the session and operator callers and requiring byte-identical YAML *and* identical harness `config_dwh_binding()` output. - [ ] **Step 2: Write RED identity/safety tests** for snapshot replacement, wrong commit/path, symlink/hardlink, wrong workspace ID, unstable config destination, same-revision changed bytes, mode, fsync/rename failure, and cleanup. - [ ] **Step 3: Run:** ```bash cd backend npx vitest run \ test/workspace-runtime-config-lease.test.ts \ test/tht-runner.test.ts \ test/workspace-runtime-handoff.test.ts ``` Expected: RED only because `runtime-config-lease.ts` and the new shared-lease behavior do not yet exist; existing `ThtRunner` regressions remain green. - [ ] **Step 4: Move snapshot validation, runtime roots, installation-overlay parsing, binding resolution, and rendering** out of `ThtRunner` into one explicit-input component. - [ ] **Step 5: Preserve session semantics but replace the random source path**: `ThtRunner.acquireWorkspaceRuntime` delegates to the component and opens the same verified deterministic `/data/sessions//preprocessing/runtime-config/.yaml` publication used by maintenance. It may retain an opaque FD for TOCTOU resistance, but the harness-visible `-c` source is that canonical path in both modes; no `/dev/fd/*` or random temporary filename may enter schema-v1 binding. - [ ] **Step 6: Add operator mode**: deterministically publish `/data/sessions//preprocessing/runtime-config/.yaml` plus a manifest, mode `0400/0600`. Do not add any operator-only rendered field: `collection_lifecycle: require_existing` and `egress.http_private_host_allowlist` are inputs to the one shared render and therefore appear identically in the session lease. - [ ] **Step 7: Prove same-revision rerun path identity** and changed config/binding refusal. Do not implement P3 semantic cross-revision canonicalization. - [ ] **Step 8: Run focused GREEN gates:** ```bash cd backend npx vitest run \ test/workspace-runtime-config-lease.test.ts \ test/tht-runner.test.ts \ test/workspace-runtime-handoff.test.ts npx tsc --noEmit -p . npm run build ``` Expected: all selected Vitest tests PASS, TypeScript exits 0, and the build emits `dist/` without changing tracked files. - [ ] **Step 9: Commit:** `refactor: share registry runtime configuration leases`. ### Task 5: Build the retained root lease, durable state, and addressed registry publication **Files:** - Create: `backend/native/workspace-fs-at/workspace_fs_at.cc` - Create: `backend/native/workspace-fs-at/binding.gyp` - Create: `backend/src/native/workspace-fs-at-binding.d.ts` - Create: `backend/src/workspaces/workspace-fs-at.ts` - Create: `backend/scripts/build-workspace-fs-at.mjs` - Create: `backend/test/workspace-fs-at-native.test.ts` - Create: `backend/test/fixtures/workspace-fs-at-race-worker.mjs` - Create: `backend/src/workspaces/workspace-lock-root-lease.ts` - Create: `backend/test/workspace-lock-root-lease.test.ts` - Create: `backend/test/workspace-session-readers-lock.test.ts` - Create: `backend/test/fixtures/workspace-session-readers-worker.mjs` - Create: `backend/src/workspaces/preprocessing-state.ts` - Create: `backend/test/workspace-preprocessing-state.test.ts` - Create: `backend/src/workspaces/registry-publication.ts` - Create: `backend/test/workspace-registry-addressed-publication.test.ts` - Create: `backend/test/workspace-registry-addressed-process.test.ts` - Create: `backend/test/registry-pull-job-exports.test.ts` - Create: `backend/test/fixtures/workspace-lock-root-worker.mjs` - Create: `backend/test/fixtures/workspace-registry-addressed-worker.mjs` - Modify: `backend/src/workspaces/registry.ts` - Modify: `backend/test/workspace-registry.test.ts` - Modify: `backend/src/routes/workspaces.ts` - Modify: `backend/test/routes-workspaces.test.ts` - Modify: `backend/src/app.ts` - Create: `backend/test/app.test.ts` - Modify: `backend/package.json` and `backend/package-lock.json` (Node 22 engine, exact `node-gyp@11.2.0`, exact `fs-ext@2.1.1` for flock only, native build scripts) - Modify: `harness/tht/cli/preprocess_cmd.py` - Modify: `harness/tht/cli/schema_cmd.py` - Modify: `harness/tht/cli/vector_cmd.py` - Create: `harness/tht/workspace_writer_lock.py` - Create: `harness/tests/test_workspace_writer_lock.py` - Modify: `harness/tests/test_preprocess_cli.py` - Modify: `harness/tests/test_schema_fk_annotations.py` - Modify: `harness/tests/test_qdrant_cli_commands.py` - Modify: `docker/core.Dockerfile` to compile the repo-owned addon in an explicit compiler stage, copy only its `.node` output plus runtime dependencies, and smoke-load it in the compiler-free Node 22 Linux runtime - [ ] **Step 1: Write RED native-seam and canonical-root factory tests.** In `workspace-fs-at-native.test.ts`, load only the typed wrapper and prove the exact Node-API v8 exports, Node 22 gate, descriptor ownership, close-on-conversion-failure, explicit/idempotent wrapper close, no raw flags/path/FD export, exact `LockFileName = "writer.lock" | "session-readers.lock"`, and stable errno mapping. Prove both names open only as `0600` regular locks and every other name/mode is rejected. With real `fs-ext@2.1.1`, spy and contend on `flockOwnedLock` to prove exact shared/exclusive and blocking/nonblocking mapping (`sh`/`ex`/`shnb`/`exnb`), actual writer exclusion, compatible shared reader acquisition, nonblocking contention, blocking release/wakeup, no numeric escape, and close exclusion during both locking and child-stdio duplication. Negative cases cover empty/`.`/`..`/slash/NUL/ 256-byte components, symlink/non-directory/wrong handle kind, forged/cross-addon/closed handles, `EEXIST`, `ENOENT`, `ELOOP` or platform-equivalent no-follow refusal, and `EBADF`; a source-level assertion rejects any close retry loop and checks the frozen close-uncertain branch. The race worker repeatedly substitutes parent and leaf directories/files/symlinks between `fstatat`, `mkdirat`, `openat`, and fsync; every iteration must return the retained inode, the verified winning creator, or a stable fail-closed error—never the attacker inode. Run the same suite on Linux and Darwin with the frozen directory-fsync behavior, and require real fsync success in the Linux container. Then instantiate two factories against distinct canonical installation session roots. Prove `canonicalInput` accepts only canonical IDs, never a path; factory A rejects factory B's input; acquisition retains the original root FD/device/inode across pathname replacement; relative opens detect replacement; and forged objects, symlinks, hardlinks, wrong ownership/mode, and non-directory components fail. Prove one owner, callback-only borrow, atomic transfer invalidating the source, idempotent close, close waiting for a borrow, and use-after-transfer/close rejection. For `acquireOrProvision`, cover absent added and absent never-used removed workspace leaves, exact UID/`0700`, anchored no-follow `mkdirat`/open, leaf and parent fsync, concurrent creators/`EEXIST`, parent or leaf substitution, symlink races, failure before publication retaining the unused leaf, and successful subsequent retry/revalidation. Add a subprocess root-replacement race at derivation/open, provision/open, open/lock-open, and callback settlement. Compile the exact public reader surface: `acquireSessionReadersShared()` has no argument and returns only `Promise`; that lease exposes only diagnostic `rootIdentity`, `transfer()`, and `close()`. Source/type fences reject a directory handle, path, numeric FD, lock name, flags/mode, arbitrary open/flock callback, public constructor, or cast escape on either type. - [ ] **Step 2: Run the native-build/root-lease RED tests:** ```bash cd backend npm run build:native npx vitest run test/workspace-fs-at-native.test.ts \ test/workspace-lock-root-lease.test.ts \ test/workspace-session-readers-lock.test.ts ``` Expected: FAIL because the committed addon/build driver, typed wrapper, exported factory, `acquireOrProvision`, opaque root lease, and P2-owned shared reader-lock lease do not exist. - [ ] **Step 3: Implement the pinned native seam and `workspace-lock-root-lease.ts`.** Write the raw Node-API v8 C++ addon, exact declaration, binding.gyp, Node-22/offline node-gyp build driver, and sole wrapper with the frozen flags, result stat, ownership, error, Linux, and Darwin contracts. Implement exact `LockFileName`, `openOrCreateLockAt(..., 0o600)`, and typed `flockOwnedLock`; its module-private synchronous numeric borrow calls exact `fs-ext@2.1.1.flockSync` for the four typed modes. The only other authorized numeric-borrow consumer is module-private `duplicateForChildStdio`, which exposes no FD. Pin exact lockfile inputs; do not use `fs-ext` for any filesystem-at operation. Make the root factory depend only on `WorkspaceFsAtV1` and use its retained installation-parent FD, component-relative no-follow directory opens, factory provenance, exact service UID/`0700`, private provisioning serialization, fsync and identity rechecks, retained FD plus device/inode, single ownership, scoped borrowing, atomic transfer, and idempotent close. Implement `acquireSessionReadersShared()` and `WorkspaceSessionReadersLockLease` exactly as frozen: wrapper-only literal open plus shared/nonblocking flock, successful root consumption, sole-owner transfer, lock-then-root close, one close attempt per owned object, and stable fail-closed cleanup. Retain unused provisioned leaves after all failures. No string overload, branded path, persistent FD accessor, public numeric-borrow callback, public lease constructor, caller path, or unanchored filesystem call survives. - [ ] **Step 4: Write RED state/kernel-capability tests.** Cover valid state, exact replay, mismatches, tampering, traversal, modes, atomic failure, and bounds. Two processes/containers contend; one wins; SIGKILL releases the kernel lock. Prove writer acquisition invokes `flockOwnedLock` with `"exclusive", "nonblocking"` on the owned `writer.lock` handle (and therefore the real `fs-ext.flockSync`, not a mock ownership marker). Prove `runUnderOrderedWorkspaceWriterLocks` itself—not nested `runUnderWorkspaceWriterLock` calls—sorts A/B lexically, holds both root/writer open descriptions simultaneously for one callback, gives the production lifecycle owner the same callback-scoped set, and invalidates captured set/capabilities before reverse B/A close on success, throw, cancellation, and partial acquisition. Prove the one-lock API delegates to this sole producer. Every mutating child receives the locked writer OFD as FD 3 and the retained root directory OFD as FD 4; Python validates root identity, relative no-follow `writer.lock` identity, type/link/mode/UID, and already-held `flock`. Missing/closed/substituted/renumbered/cross-root FD 3 or FD 4, independent lock descriptions, forged markers, request/runtime-config/root mismatch, concurrent spawn misuse, and post-settlement use all fail before writes and close exactly once. In the dedicated `workspace-session-readers-lock.test.ts`, call the production methods rather than a shadow factory. For session ownership, acquire a root, call `acquireSessionReadersShared()`, prove the source root is consumed only after success, transfer the lease into a fixture session owner, and keep a real child process plus stdout/stderr drain open; an exclusive production callback must contend until child exit, stream settlement, and the owner's exactly-once teardown close, after which it succeeds. Cover spawn, configure, child `exit`, child `close`, replacement, explicit teardown, and shutdown-shaped failure paths with exactly one final lease close and no early unlock. Prove two production shared acquisitions coexist; shared blocks exclusive; exclusive blocks a new shared acquisition; release permits the contender; and root/path substitution always fails closed. Compile and call the exact maintenance signature `runUnderSessionReadersExclusive(action: (lease: BorrowedWorkspaceSessionReadersExclusiveLockLease) => Promise): Promise`. Prove it invokes only `openOrCreateLockAt(retainedRoot, "session-readers.lock", 0o600)` and `flockOwnedLock(owned, "exclusive", "nonblocking")`; holds the real lock across awaited work; rejects contention without entering the callback; rejects nested/concurrent calls and acquisition while an earlier child is in flight; allows the callback's awaited closed-union `spawnChild` and holds the reader lock through its teardown; invalidates a captured borrow before settlement so `assertLive()` fails afterward; and closes the owned lock once on resolve, throw, cancellation, and acquisition failure. Inject production-wrapper close failure for shared acquisition cleanup, shared lease teardown, and exclusive callback cleanup: require no retry, all remaining owned objects still receive their one close attempt, the owner/capability stays invalid/poisoned, the outer writer owner cannot report success, and the public failure is stable `preprocessing_conflict` with no FD/path/name leak. Type/source fences allow only P2 `workspace-fs-at.ts` to open/flock and reject raw addon/`fs-ext` imports, directory-handle/FD/path/name/flags fields or parameters, casts, alternate factories, and a path fallback. - [ ] **Step 5: Write RED addressed-registry, recovery-selector, and route tests.** Compile-check the exact create/resume variants, installation/repository/remote identities, results, recovery identity, scan limits, and the discriminated `RegistryEnsureBootstrapAddressedResultV1` signature, including exact `{ kind: "already_active", snapshot }` and `{ kind: "bootstrap_terminal", result, snapshot }` branches. Empty bootstrap has `baseCommit`/base digest `null`, base workspace list `[]`, and every target ID as its lexical changed set; existing-active pull has full base/target identities and the exact symmetric changed set. Under a held `repository.lock`, prove the active pointer/snapshot is rechecked before any job-directory open: valid active returns `already_active` with no scan/network; corrupt/incompatible active fails closed with no scan/network; absence alone reaches the exact bounded sorted directory scan. There, zero nonterminal jobs creates one ID; exactly one matching nonterminal resumes that exact ID before a network spy fires; a valid deterministic pre-claim/transition sibling is cleaned+directory-fsynced and zero creates, while malformed/cross-owned/hardlinked/duplicate siblings fail closed; valid terminal jobs do not enter selection and exact same-ID terminal resume replays stored result without network; multiple nonterminals, sole pull, request/install/repo/remote mismatch, invalid name/type/ mode/link/JSON/hash/phase/run ID, entry churn, and each entry/per-file/aggregate bound fail with `registry_bootstrap_recovery_conflict`. Prove no mtime/newest/lexical-last/ remote-head heuristic is observable. Exercise inspect, status, and lazy list/bootstrap through `ensureBootstrapAddressed`; each adapter must consume both union branches and use the returned exact snapshot, including exact HTTP 409/code-only conflict bodies, and pull/author publication through `publishAddressed`, including app dependency wiring. Prove no callable old `bootstrap`, `pull`, `activate`, or direct pointer publication path remains and every caller reaches the same production lifecycle owner and complete ordered set. - [ ] **Step 6: Write RED durable phase and process-kill tests.** Assert repository lock precedes a `0600` `addressed-publication-jobs/.json` claim and that `request_claimed` rename+parent-fsync completes before any network. Cover kills before claim rename, after claim fsync, during/after advertisement but before durable pin, after `target_advertised` fsync, during/after exact-OID fetch, after immutable-ref creation but before `target_fetched`, after its fsync, before/after `planned`, every participant phase, and before/after active-pointer rename/fsync. Before the advertised pin, same-ID resume may re-advertise; afterward it must retain that exact OID, reuse an already-correct run ref without network, retry only the pinned exact OID when the ref is absent, reject a mismatched ref, never fetch after `target_fetched`, and replay terminal success despite later tracking-ref drift. Repeat every preterminal kill through the actual inspect, lazy-list, and status product callers: after restart each must enter the same selector, resume the same ID before network when the one matching job exists, and fail closed on multiple/corrupt/mismatched jobs. Add real two- and three-process barriers where inspect, lazy list, and status all observe initial absence before queueing on `repository.lock`: prove exactly one run claim, advertisement/fetch, publication, and terminal job; queued callers return `already_active` before scanning jobs or touching network; and all three adapters expose the same commit/manifest/workspace snapshot. Repeat with corrupt active state after lock handoff and require fail-closed/no-job/no-network behavior. Include absent added/removed roots, concurrent provisioning, complete-set contention, participant failure, concurrent session admission, all-or-nothing base/target visibility, third-identity refusal, and reverse readers/quiescence/writer/root release. - [ ] **Step 7: Run all RED files:** ```bash cd backend npm run build:native npx vitest run test/workspace-fs-at-native.test.ts \ test/workspace-lock-root-lease.test.ts \ test/workspace-session-readers-lock.test.ts \ test/workspace-preprocessing-state.test.ts \ test/workspace-registry-addressed-publication.test.ts \ test/workspace-registry-addressed-process.test.ts \ test/registry-pull-job-exports.test.ts \ test/workspace-registry.test.ts \ test/routes-workspaces.test.ts \ test/app.test.ts ``` Expected: FAIL on the missing native addon/wrapper, retained/provisioned root, opaque shared/exclusive reader operations, ordered callback producer, FD-3/FD-4 capability, bootstrap/pull variants, bounded automatic recovery selector, pre-plan durable phases, route delegation, and addressed publication interfaces; no contender may exceed the five-second test timeout. - [ ] **Step 8: Implement state and writer capability ownership.** Implement `runUnderOrderedWorkspaceWriterLocks(rootLeases, action)` as the sole capability/set producer with lexical acquisition through `openOrCreateLockAt(root, "writer.lock", 0o600)` followed by `flockOwnedLock(lock, "exclusive", "nonblocking")`, callback-only borrows, and reverse invalidation/close. Make the one-workspace API its one-element adapter. A capability owns its root lease until settlement. Implement its exact no-operand `runUnderSessionReadersExclusive(action)` with wrapper-only literal open, exclusive/nonblocking flock, callback-borrow invalidation, one close attempt, poisoning on close uncertainty, and no handle/path/FD/name surface. Make the production lifecycle owner nest these callbacks in lexical order and pass their exact borrowed leases to participants. `spawnChild` accepts only the frozen request union, remains usable from inside the exclusive reader callback, installs writer OFD 3 plus root-directory OFD 4 for every mutating child, and performs exact workspace/revision/config/root checks. Implement the shared Python FD-3/FD-4 verifier and call it before every mutating harness path; preserve the same-union extension seam for P3/P5/P6 and add no raw argv/path/direct-spawn overload. - [ ] **Step 9: Implement addressed claim/fetch/plan/publication and automatic recovery.** Implement the full P2 `RegistryAddressedPublicationStateV1`, all three installation/repository/remote identity digests, canonical bootstrap request digest, artifact path, `request_claimed`, `target_advertised`, `target_fetched`, and `planned` transitions plus both exact plan variants. Implement the one bounded no-follow `ensureBootstrapAddressed` active-recheck/scan/zero-create/sole-exact-resume/fail-closed selector while continuously holding `repository.lock`. Before the jobs scan, return `{ kind: "already_active", snapshot }` for a strictly valid active snapshot and reject corrupt active state; after publication return `{ kind: "bootstrap_terminal", result, snapshot }` from a strict locked reread. Share a private already-locked executor with `publishAddressed` rather than nesting/releasing the lock. Persist the claim before network, durably pin the advertisement OID before exact-OID fetch to the create-only run ref, and implement the frozen pre-pin/post-pin resume and exact-identity terminal replay rules. For every changed ID call `acquireOrProvision` while retaining `repository.lock`, then pass all leases once to `runUnderOrderedWorkspaceWriterLocks`; keep quiescence, each exact `writerCapability.runUnderSessionReadersExclusive(...)` callback, participants, synchronizers, atomic active publication/reconciliation, and `terminal_durable` inside the production lifecycle-owner callback, followed by reverse reader-borrow invalidation/close and repository release. - [ ] **Step 10: Route every registry caller through the sole addressed APIs.** Implement `WorkspaceRegistry.ensureBootstrapAddressed` and `publishAddressed`; delete/private the old bootstrap/pull/activate/pointer methods. Make inspect, lazy list/bootstrap, and status delegate only to the automatic selector and exhaustively adapt both `already_active` and `bootstrap_terminal` to the returned exact snapshot; make pull and author activation delegate to `publishAddressed`; update routes and `app.ts` construction. Preserve accepted HTTP request shapes (there is no bootstrap run-ID field) while returning the stable fail-closed code. Synchronizers/adapters may not call either writer-lock function or acquire capabilities. - [ ] **Step 11: Run GREEN gates:** ```bash cd backend npm run build:native npx vitest run test/workspace-fs-at-native.test.ts \ test/workspace-lock-root-lease.test.ts \ test/workspace-session-readers-lock.test.ts \ test/workspace-preprocessing-state.test.ts \ test/workspace-registry-addressed-publication.test.ts \ test/workspace-registry-addressed-process.test.ts \ test/registry-pull-job-exports.test.ts \ test/workspace-registry.test.ts \ test/routes-workspaces.test.ts \ test/app.test.ts npx tsc --noEmit -p . npm run build node -e 'if (process.versions.node.split(".")[0] !== "22" || Number(process.versions.napi) < 8) process.exit(1); require("./native/workspace-fs-at/build/Release/workspace_fs_at.node")' cd .. docker build --target backend-native-build -f docker/core.Dockerfile . docker build --target backend-build -f docker/core.Dockerfile . docker build -f docker/core.Dockerfile -t thothii-core:p2-native . docker run --rm --entrypoint node thothii-core:p2-native -e 'const a=require("/app/backend/native/workspace-fs-at/build/Release/workspace_fs_at.node"); const r=a.openat({parent:null,name:"/",kind:"directory",createMode:0}); const t=a.openat({parent:r.handle,name:"tmp",kind:"directory",createMode:0}); a.fsyncDirectory(t.handle); a.close(t.handle); a.close(r.handle); require("fs-ext")' cd harness .venv/bin/pytest -q tests/test_workspace_writer_lock.py \ tests/test_preprocess_cli.py \ tests/test_schema_fk_annotations.py \ tests/test_qdrant_cli_commands.py ``` Expected: all selected Linux/Darwin-appropriate tests PASS, including real shared/exclusive contention, session transfer/child teardown, maintenance callback invalidation, and injected close-failure cases; TypeScript exits 0, the Node 22/N-API v8 load check succeeds, both Linux image stages build, the compiler-free runtime loads the copied addon, and harness tests pass. - [ ] **Step 12: Commit:** ```bash git add backend/native/workspace-fs-at/workspace_fs_at.cc \ backend/native/workspace-fs-at/binding.gyp \ backend/src/native/workspace-fs-at-binding.d.ts \ backend/src/workspaces/workspace-fs-at.ts \ backend/scripts/build-workspace-fs-at.mjs \ backend/test/workspace-fs-at-native.test.ts \ backend/test/fixtures/workspace-fs-at-race-worker.mjs \ backend/src/workspaces/workspace-lock-root-lease.ts \ backend/test/workspace-lock-root-lease.test.ts \ backend/test/workspace-session-readers-lock.test.ts \ backend/test/fixtures/workspace-session-readers-worker.mjs \ backend/src/workspaces/preprocessing-state.ts \ backend/test/workspace-preprocessing-state.test.ts \ backend/src/workspaces/registry-publication.ts \ backend/test/workspace-registry-addressed-publication.test.ts \ backend/test/workspace-registry-addressed-process.test.ts \ backend/test/registry-pull-job-exports.test.ts \ backend/test/fixtures/workspace-lock-root-worker.mjs \ backend/test/fixtures/workspace-registry-addressed-worker.mjs \ backend/src/workspaces/registry.ts backend/test/workspace-registry.test.ts \ backend/src/routes/workspaces.ts backend/test/routes-workspaces.test.ts \ backend/src/app.ts backend/test/app.test.ts \ backend/package.json backend/package-lock.json \ harness/tht/cli/preprocess_cmd.py harness/tht/cli/schema_cmd.py \ harness/tht/cli/vector_cmd.py harness/tht/workspace_writer_lock.py \ harness/tests/test_workspace_writer_lock.py harness/tests/test_preprocess_cli.py \ harness/tests/test_schema_fk_annotations.py harness/tests/test_qdrant_cli_commands.py \ docker/core.Dockerfile git commit -m "feat: add durable addressed registry publication" ``` ### Task 6: Build the compiled inspect/operator boundary **Files:** - Create: `backend/src/workspace-maintenance.ts` - Create: `backend/src/workspaces/preprocessing-service.ts` - Create: `backend/test/workspace-maintenance.test.ts` - Create: `backend/test/workspace-preprocessing-service.test.ts` - Modify: `backend/src/workspaces/types.ts` only if a separate operator-code union cannot stay private - [ ] **Step 1: Write RED entrypoint process tests** for exact JSON, malformed/extra stdin, unknown command/field, stdout/stderr bounds, timeout, signal, raw exception/stderr redaction, and no Fastify listener. - [ ] **Step 2: Write RED `inspect` tests** for successful automatic locked bootstrap when active state is absent, zero-job create, sole-exact-ID nonterminal resume before network, multiple/corrupt/ identity-mismatched fail-closed output, and exact terminal replay behavior. Prove inspect has no bootstrap-resume input field and delegates to the same `ensureBootstrapAddressed` spy as lazy list and status. Exercise both result branches: a queued stale-absence caller must consume `already_active` and the exact returned snapshot with no scan/fetch/pull, while bootstrap consumes `bootstrap_terminal`; corrupt/stale active state after lock acquisition must fail closed. Also cover active-state read without a fetch/pull, migration-required descriptor, missing bindings, exact commit/blob/config identities, safe capability warnings, and no URL/secret/job-ID output. - [ ] **Step 3: Run the RED files:** ```bash cd backend npx vitest run \ test/workspace-maintenance.test.ts \ test/workspace-preprocessing-service.test.ts ``` Expected: FAIL because the compiled entrypoint/service do not exist; no test may open a listener. - [ ] **Step 4: Implement a closed `WorkspacePreprocessingService` dependency interface**: active registry reader, shared config lease, fixed child runner, state store, session inventory, semantic preflight, egress policy. - [ ] **Step 5: Implement pinned snapshot acquisition.** Only when its initial unlocked read observes active state absent, `inspect` derives `RegistryBootstrapRecoveryIdentityV1` from the validated installation/repository/remote and calls `WorkspaceRegistry.ensureBootstrapAddressed`; it never constructs or selects a run ID. The selector rechecks active state under its retained repository lock, returns the `already_active` snapshot to stale queued callers, or creates/resumes and returns the `bootstrap_terminal` snapshot; the service exhaustively consumes either branch rather than rereading an unlocked pointer. Corrupt active state fails closed. With an active state, no maintenance command fetches or pulls. Mutating commands never bootstrap or activate Git and return `workspace_not_activatable` with safe instructions if the inspect/bootstrap prerequisite is absent. - [ ] **Step 6: Implement bounded child execution** with fixed executable/argv, `-c` after the subcommand and pointing at the verified deterministic revision config, FD-backed ingress only, process-group cancellation, per-stage timeout, and strict one-document child JSON parsing. - [ ] **Step 7: Implement `inspect` and result encoding.** - [ ] **Step 8: Run GREEN gates:** ```bash cd backend npx vitest run \ test/workspace-maintenance.test.ts \ test/workspace-preprocessing-service.test.ts npx tsc --noEmit -p . npm run build test -f dist/workspace-maintenance.js ``` Expected: both Vitest files PASS, typecheck/build exit 0, and the compiled entrypoint exists. - [ ] **Step 9: Commit:** `feat: add P2 workspace maintenance operator`. ### Task 7: Implement DWH preprocessing and outer resume **Files:** - Modify: `backend/src/workspaces/preprocessing-service.ts` - Modify: `backend/test/workspace-preprocessing-service.test.ts` - Modify: `harness/tests/test_dwh_preprocess_job.py` - Modify: `harness/tests/test_lsh_job_resume.py` - [ ] **Step 1: Write RED service tests** requiring fixed `preprocess dwh --steps introspect,lsh --json -c `, child result validation, outer/child run IDs, completed stages, safe artifact digests, and failure mapping. - [ ] **Step 2: Add real harness regressions** for deterministic same-revision config-source path, second clean-process rerun, resume after introspection, changed binding/config refusal, and ACTIVE preservation on failure. - [ ] **Step 3: Run the RED focused tests:** ```bash cd backend npx vitest run test/workspace-preprocessing-service.test.ts -t 'DWH|resume' cd ../harness .venv/bin/pytest -q tests/test_dwh_preprocess_job.py tests/test_lsh_job_resume.py ``` Expected: new backend DWH coordinator tests FAIL before implementation; existing harness tests stay green except newly added same-revision identity assertions. - [ ] **Step 4: Implement `preprocess dwh`** under the outer writer lock and persist state before/after every child transition. - [ ] **Step 5: Reconcile a published child run after an injected outer crash** without rerunning or corrupting ACTIVE. - [ ] **Step 6: Verify REST and direct rendered routing.** SSH returns `workspace_not_activatable` with a P10 warning. - [ ] **Step 7: Run GREEN focused gates:** ```bash cd backend npx vitest run test/workspace-preprocessing-service.test.ts -t 'DWH|resume' npx tsc --noEmit -p . cd ../harness .venv/bin/pytest -q tests/test_dwh_preprocess_job.py tests/test_lsh_job_resume.py .venv/bin/ruff check tht/cli/preprocess_cmd.py tests/test_dwh_preprocess_job.py tests/test_lsh_job_resume.py ``` Expected: all selected tests PASS, TypeScript exits 0, and touched Python is Ruff-clean. - [ ] **Step 8: Commit:** `feat: run workspace DWH preprocessing from thothctl operator`. ### Task 8: Implement FK candidate export and digest-bound review **Files:** - Modify: `backend/src/workspaces/preprocessing-service.ts` - Modify: `backend/src/workspaces/preprocessing-state.ts` - Modify: relevant backend tests - Modify: `tools/thothctl/internal/workspaceops/operations.go` - Modify: Go tests - [ ] **Step 1: Write RED end-to-end unit/process tests**: new candidates create a bounded artifact and return `manual_review_required` with its 32-hex outer run ID; `schema check` without `--resume`, with another run's ID, or with a same-digest ambiguous run is refused; the exact resume ID loads only that run; schema/Evidence child calls are absent. Run: ```bash cd backend npx vitest run test/workspace-preprocessing-service.test.ts -t 'FK|manual review' cd ../tools/thothctl go test ./internal/workspaceops ./cmd/thothctl -run 'Fk|Annotation|Export' -v ``` Expected: FAIL because FK dispatch, digest review, and exclusive export are not implemented. - [ ] **Step 2: Add safe host ingress tests** proving SQL and annotations travel only over stdin, are absent from Compose argv/state/logs, and staging files are removed. - [ ] **Step 3: Add safe host egress tests** for candidate export identity/digest, existing destination refusal, and sanitized JSON mode. - [ ] **Step 4: Implement `schema suggest-fks`** with candidate count/digest and optional exclusive output. - [ ] **Step 5: Implement `schema check`** in two modes: read-only orphan validation; or reviewed annotation import requiring the exact candidate digest. Both modes require `--resume <32-hex-outer-run-id>`, call `PreprocessingStateStore.loadForResume` for that exact workspace/revision/operation, and never select by candidate digest. Persist outer run ID + review digest + annotation digest + workspace/revision. - [ ] **Step 6: Require the review record on full-run resume.** A mere zero-orphan result without reviewer digest is insufficient. - [ ] **Step 7: Preserve the boundary:** P2 updates runtime-local annotations only; it never writes Git. Output warns that P5 will supersede this local acknowledgement. - [ ] **Step 8: Run GREEN focused gates:** ```bash cd backend npx vitest run test/workspace-preprocessing-service.test.ts -t 'FK|manual review' npx tsc --noEmit -p . cd ../tools/thothctl go test ./internal/workspaceops ./cmd/thothctl -run 'Fk|Annotation|Export' -v cd ../../harness .venv/bin/pytest -q tests/test_schema_fk_annotations.py .venv/bin/ruff check tht/cli/schema_cmd.py tests/test_schema_fk_annotations.py ``` Expected: all selected tests PASS and no candidate/annotation bytes appear in captured argv, logs, or public JSON. - [ ] **Step 9: Commit:** `feat: add P2 FK review checkpoint`. ### Task 9: Implement schema indexing and Evidence policy boundaries **Files:** - Modify: `backend/src/workspaces/preprocessing-service.ts` - Modify: `backend/src/workspaces/runtime-renderer.ts` - Modify: backend service/renderer/config-lease tests - Modify: `tools/thothctl/internal/config/installation.go` and tests - Modify: `harness/tht/config.py` - Modify: `harness/tht/adapters/factory.py` - Modify: `harness/tht/adapters/evidence/http.py` - Modify: `harness/tests/test_http_evidence_source.py` - Modify: `harness/tests/test_registry_evidence_config.py` - Modify: `harness/tests/test_semantic_kind_isolation.py` - [ ] **Step 1: Write RED schema-index tests** for compatible pre-existing collection, deterministic JSON counts, idempotent repeat, missing/incompatible refusal, and no collection-create request. Run: ```bash cd backend npx vitest run test/workspace-preprocessing-service.test.ts -t 'schema index|Evidence|egress' cd ../harness .venv/bin/pytest -q \ tests/test_http_evidence_source.py \ tests/test_registry_evidence_config.py \ tests/test_semantic_kind_isolation.py ``` Expected: the new operator boundary assertions FAIL; existing adapter tests remain green. - [ ] **Step 2: Write RED Evidence tests** for no-Evidence warning/skip, HTTP dry-run/run/resume/unchanged/mutation, authoritative workspace identity, ACTIVE preservation, and filesystem early stop before adapter/Qdrant calls. - [ ] **Step 3: Write RED tests at the real Python request boundary.** Cover the exact `THT_HTTP_PRIVATE_HOST_ALLOWLIST` grammar, session/operator rendered byte and `config_dwh_binding()` equality, public DNS, an allowlisted RFC1918/IPv6-ULA hostname, non-allowlisted private DNS, direct private IP literals, redirect chains, redirect to private/link-local/metadata, DNS rebinding where the connected peer differs from the approved resolution, mixed safe/unsafe DNS answers, signed-query redaction, and refusal of S3 ambient/custom/private/insecure modes. - [ ] **Step 4: Implement the exact installation setting and shared rendering.** Parse at most 32 canonical lower-case ASCII DNS names (253 bytes each), no IPs/wildcards/trailing dot/duplicates, and render `egress.http_private_host_allowlist` identically into session and maintenance YAML. The descriptor's `allow_private_hosts` is necessary but never sufficient for private access; the exact hostname must also be installation-allowlisted. - [ ] **Step 5: Enforce policy inside `HttpManifestEvidenceSource`, not in a Node-only preflight.** Disable automatic redirects. Before each hop, freshly resolve every A/AAAA result; refuse the hop if any address is loopback, link-local, metadata (`169.254.169.254` and IPv6 equivalents), multicast, unspecified, reserved/documentation/benchmark, or otherwise non-routable. RFC1918 and IPv6 ULA are allowed only for a descriptor-enabled exact allowlisted DNS hostname. Connect, then verify the actual peer IP belongs to that hop's approved resolution and class before accepting response bytes. Resolve and peer-check again for every redirect; a changed/rebound peer fails closed. Descriptor `allow_private_hosts` alone never bypasses any DNS or peer check. - [ ] **Step 6: Implement `index-schema` and `preprocess evidence`** with semantic preflight and stable result mapping. Hold the shared workspace writer lock across every mutating publication; active revision cannot change because registry activation honors the same lock. Immediate revision/session rechecks remain defense in depth. - [ ] **Step 7: Run GREEN focused gates:** ```bash cd backend npx vitest run test/workspace-preprocessing-service.test.ts -t 'schema index|Evidence|egress' npx tsc --noEmit -p . npm run build cd ../harness .venv/bin/pytest -q \ tests/test_http_evidence_source.py \ tests/test_registry_evidence_config.py \ tests/test_semantic_kind_isolation.py .venv/bin/ruff check \ tht/cli/preprocess_cmd.py tht/cli/vector_cmd.py tht/config.py \ tht/adapters/factory.py tht/adapters/evidence/http.py \ tests/test_http_evidence_source.py tests/test_registry_evidence_config.py \ tests/test_semantic_kind_isolation.py ``` Expected: all selected tests PASS; filesystem returns `evidence_materialization_required` before adapter discovery; collection-create call count is zero. - [ ] **Step 8: Commit:** `feat: add guarded P2 semantic preprocessing`. ### Task 10: Implement the ordered full-run coordinator **Files:** - Modify: `backend/src/workspaces/preprocessing-service.ts` - Modify: `backend/test/workspace-preprocessing-service.test.ts` - Modify: `backend/test/workspace-maintenance.test.ts` - [ ] **Step 1: Write a RED stage-table test** for exact order `dwh → fk_suggest → fk_review/check → schema_index → evidence` and for no hidden/skipped mutation. Run: ```bash cd backend npx vitest run test/workspace-preprocessing-service.test.ts -t 'full run|stage order' ``` Expected: FAIL because the full-run state machine is not implemented. - [ ] **Step 2: Add scenarios**: pre-curated/no-new-candidate completion; new-candidate block; digest-reviewed resume; no-Evidence warning; filesystem deferred block; each child failure; resume mismatch; outer crash reconciliation. - [ ] **Step 3: Implement the coordinator as an explicit state machine**, not recursive command dispatch. - [ ] **Step 4: Persist completion after each verified child artifact** and never mark a stage based only on exit code. - [ ] **Step 5: Prove unchanged rerun creates no duplicate schema/Evidence points or generation.** - [ ] **Step 6: Run GREEN coordinator gates:** ```bash cd backend npx vitest run test/workspace-preprocessing-service.test.ts -t 'full run|stage order' npx tsc --noEmit -p . npm run build ``` Expected: all coordinator scenarios PASS; blocked paths prove later child call count is zero. - [ ] **Step 7: Commit:** `feat: orchestrate the P2 preprocessing chain`. ### Task 11: Add the hardened maintenance service and selected-image handoff **Files:** - Modify: `compose.yaml` - Modify: `deploy/compose.local.yaml` - Modify: `deploy/compose.server.yaml` - Modify: connector/Git override files and generators as required - Create: `docker/workspace-maintenance-entrypoint.sh` - Modify: `docker/core.Dockerfile` - Modify: `tools/thothctl/internal/workspaceops/operations.go` - Modify: `tools/thothctl/internal/pi/update.go` - Modify relevant Go/Bash/Compose tests - [ ] **Step 1: Write RED Compose contract tests** for the exact security contract, profile, mounts, no Pi/no port/no build, local/server storage, and Git credentials attached only to inspect's empty-registry bootstrap capability. - [ ] **Step 2: Write RED image-precedence tests** across base/profile/operator overrides/current-image override; `core` and maintenance must resolve to the same immutable ID. - [ ] **Step 3: Write RED lifecycle tests** for `--no-deps`, pre-existing service preservation, interruption cleanup, hostile container name/label collision, tag replacement, output rejection on post-run image mismatch, and infinite/oversized maintenance stdout or stderr causing immediate owned process-group/container termination through `RunBounded`. - [ ] **Step 4: Run RED packaging contracts:** ```bash bash scripts/test-preprocess-compose-config.sh bash scripts/test-compose-secret-policy.sh cd tools/thothctl go test ./internal/workspaceops ./internal/pi -run 'Maintenance|Workspace|Image' -v ``` Expected: FAIL because `workspace-maintenance`, its entrypoint, selected-image handoff, and scoped secret override do not exist; no Compose resource is started by these contract tests. - [ ] **Step 5: Add the dedicated service and entrypoint**; the entrypoint executes only the compiled operator and never calls Pi trust setup. Extend the HTTPS/SSH transport contracts and connector generator metadata so `workspaceops.Run` can select exact inspect/DWH/Evidence files without mounting an unrelated workspace or role credential. - [ ] **Step 6: Generate a per-operation final override** that pins immutable image ID, exact secrets, egress policy, and owned labels. Validate `docker compose config` structurally before run. - [ ] **Step 7: Extend Pi update/rollback override generation** so future selected images cannot split core and maintenance. - [ ] **Step 8: Run:** ```bash bash scripts/test-preprocess-compose-config.sh bash scripts/test-compose-secret-policy.sh bash scripts/test-default-compose.sh bash scripts/test-unified-compose.sh bash scripts/test-no-deployment-coupling.sh cd tools/thothctl && go test ./... ``` Expected: every script exits 0 and every Go package PASS. Then use the contract script's owned fixture mode (add this mode in Step 1) to build and invoke the exact service: ```bash bash scripts/test-preprocess-compose-config.sh --image-smoke ``` Expected: the script prints `workspace-maintenance image smoke: PASS`; operator help exits 0; its final owned-resource assertion finds no maintenance container; core/frontend/Pi were never started. The script creates and destroys its own fixture env/overrides and never reads an operator installation. - [ ] **Step 9: Commit:** `feat: package the P2 maintenance service`. ### Task 12: Complete host dispatch and supported-platform build contract **Files:** - Modify: `tools/thothctl/cmd/thothctl/main.go`, tests - Modify: `tools/thothctl/internal/workspaceops/operations.go`, tests - Modify: `scripts/build-thothctl.sh` - Modify: `scripts/test-thothctl-build-contract.sh` - Update operator docs - [ ] **Step 1: Add RED command-to-request-to-Compose tests** for all seven public commands, JSON/human output, exit mapping, secret redaction, and exact stdin. For inspect, assert Go accepts no `--resume`/bootstrap-ID field, sends the same typed inspect request on every invocation, preserves exact JSON code `registry_bootstrap_recovery_conflict`, maps it to exit 3, and emits only the frozen safe human sentence. Run: ```bash cd tools/thothctl go test ./cmd/thothctl ./internal/workspaceops -run 'Workspace' -v ``` Expected: FAIL on dispatch/output cases not yet connected; parser-only tests from Task 1 remain green. - [ ] **Step 2: Implement dispatcher integration** using only typed requests. Add the exact Go result-code constant and exhaustive exit/human renderer case for `registry_bootstrap_recovery_conflict`; do not add a Go or HTTP bootstrap run-ID selector. - [ ] **Step 3: Cross-build the existing release matrix** and verify Windows input/output safety compiles. Do not claim Windows Docker behavior without a Windows Docker run. - [ ] **Step 4: Run GREEN native gates:** ```bash cd tools/thothctl test -z "$(gofmt -l .)" go test ./... go vet ./... cd ../.. bash scripts/test-thothctl-build-contract.sh ``` Expected: no `gofmt` output; all Go packages PASS; vet and the Linux/macOS/Windows release build contract exit 0. - [ ] **Step 5: Commit:** `feat: expose P2 workspace commands in thothctl`. ### Task 13: Build the clean-state automated P2 process goal **Files:** - Create: `scripts/p2-acceptance.sh` - Create: `backend/scripts/p2-acceptance.mjs` - Create: `backend/scripts/p2-acceptance.test.mjs` - Update: `.gitignore` only if the existing `.artifacts/` rule is insufficient The public command is: ```bash ./scripts/p2-acceptance.sh integration --keep ``` - [ ] **Step 1: Write RED acceptance-runner tests** for ownership-first state, unique run/project/container/image names, exact cleanup, `--keep`, injected failure, signal cleanup, report bounds, and no automatic retry. Run: ```bash node --test backend/scripts/p2-acceptance.test.mjs ``` Expected: FAIL because the runner and ownership/report contract do not exist. - [ ] **Step 2: Build a clean owned topology** under `.artifacts/p2-integration/p2-/`: local bare Git + author clone, an empty installation registry that `workspace inspect` bootstraps, installation descriptor/env, fixture-only secrets, controlled REST DWH, controlled HTTP Evidence, real compatible Qdrant, deterministic Ollama-compatible embedding fixture, selected core image, and no backend/Pi/frontend. - [ ] **Step 3: Pre-provision the exact compatible Qdrant collection** outside the product operation and record that setup as a P4-deferred fixture step. - [ ] **Step 4: Exercise only built `thothctl` product commands** and assert: 1. exact inspect revision/config identity; 2. DWH introspection+LSH, clean-process rerun/resume, physical/LSH artifacts; 3. FK pristine JSON, pause-before-index, candidate export, explicit digest review, resume; 4. pre-curated full-run completion; 5. schema index counts and unchanged rerun; 6. HTTP Evidence dry-run, publish, unchanged rerun, input mutation/new generation/ACTIVE; 7. no-Evidence warning/skip; 8. filesystem `evidence_materialization_required` with no partial output; 9. direct renderer regression and SSH fail-closed result; 10. missing workspace/binding, resume mismatch, different-revision resumable session, concurrent writer, annotation invalid, egress refusal (including redirect/rebinding), and semantic incompatibility; 11. session/operator rendered bytes and `config_dwh_binding()` are equal; concurrent pull/activation is refused during each mutating child and subsequent session admission remains on the unchanged active revision; 12. 700 KiB candidate export succeeds inside the 1 MiB envelope; one-byte-over/infinite stdout and stderr terminate the owned process group/container without excess capture; 13. no collection creation, no backend listener, no Pi init, no arbitrary mount; 14. exact cleanup preserving all foreign/pre-existing resources; 15. for every preterminal bootstrap kill point, restart separately through built inspect, backend lazy list, and status caller harnesses and prove same-ID resume precedes network; inject zero, multiple, corrupt, and request/install/repository/remote mismatch scans and prove the exact stable code with no newest heuristic or leaked candidate ID; then barrier-start two and all three callers after each observed absence and prove one bootstrap/network/publication, queued `already_active` results, and byte-identical returned active snapshot identity. Corrupt active state at the lock handoff must produce no job scan, run, or network. - [ ] **Step 5: Produce bounded `report.json` and `report.md`**, declare hashes for every retained owned artifact, and scan raw Git objects, names, state, reports, logs, configs, candidates, and Qdrant payloads for fixture canaries/signed queries/raw SQL. - [ ] **Step 6: Run GREEN runner tests, then one clean integration without retry:** ```bash node --test backend/scripts/p2-acceptance.test.mjs ./scripts/p2-acceptance.sh integration --keep ``` Expected: unit tests PASS; the integration exits 0 once and prints `P2 automated integration: PASS` plus one retained report path. On failure, diagnose/fix/regress and start one new clean run; never loop blindly. - [ ] **Step 7: Commit tooling:** `test: add P2 host preprocessing acceptance`. ### Task 14: Finalize P2 documentation and independent manual walkthrough **Files:** - Update: `docs/testing/p2-p6-manual-verification.md` P2 section only - Update: local/server installation manuals - Update: `docs/contracts/workspace-preprocessing-cli.md` - Optionally create manual lab helper files if concrete setup cannot remain concise - [ ] **Step 1: Document prerequisites and boundaries**: Docker/Compose, inspect/bootstrap then active registry snapshot, existing compatible collection, running semantic services for semantic commands, no host language runtimes, no backend/Pi. `docs/contracts/workspace-preprocessing-cli.md` must state that inspect exposes no bootstrap resume flag; inspect/lazy list/status all use the same automatic repository-locked bounded selector; its first locked action revalidates active state, valid active returns `already_active` before job scan/network, corrupt active fails closed, and only proven absence reaches job selection. Document that zero creates, one exact nonterminal resumes its exact ID before network, multiple/corrupt/mismatched fails closed, terminals are excluded from selection and replay only by exact identity, and no newest heuristic exists. Include the queued-caller guarantee: concurrent inspect/lazy-list/status calls publish once and converge on the same returned snapshot. Update the local/server backup inventories to state that the sessions store contains P2 `.tht-dwh`, corpus, runtime-local annotations, and preprocessing state; reuse existing volume backup mechanics and do not design P3 ownership recovery here. - [ ] **Step 2: Fill exact P2 commands** for inspect, DWH/resume, FK export/review digest, check/import, index, HTTP dry/run, full run, unchanged rerun, filesystem deferred result, secret scan, and cleanup. Document exact JSON code `registry_bootstrap_recovery_conflict`, exit 3, the sole human sentence, and lazy-list/status HTTP 409 code-only body; examples must not suggest a bootstrap run-ID selector or manual newest-job choice. - [ ] **Step 3: Explain each observed component/artifact** without exposing config or secret contents. - [ ] **Step 4: Require a new manual root and `VERDICT.md`** with reviewer, UTC time, explicit result for every P2 check, observations, and exactly `P2 manual acceptance: PASS|FAIL`. Automation never writes it. - [ ] **Step 5: Add mechanical docs tests** for all released commands and stable codes, including the automatic bootstrap recovery matrix, locked `already_active`/corrupt-active behavior, two-/three-caller convergence, absent inspect `--resume`, exact recovery-conflict JSON/exit/human output, and no newest-job wording, then run: ```bash bash scripts/test-verify-workspace-install-docs.sh bash scripts/verify-workspace-install-docs.sh --fixtures-only ``` Expected: both exit 0; the living manual contains every frozen P2 command and keeps P3–P6 sections PENDING. - [ ] **Step 6: Commit:** `docs: add P2 preprocessing operator walkthrough`. ### Task 15: Run affected-layer verification and hand off the hard checkpoint **Files:** - Modify after evidence exists: `PROJECT_STATE.md` - [ ] **Step 1: Assert `go version` is exactly Go 1.26.5, then run complete affected Go gates:** `(cd tools/thothctl && go test ./... && go vet ./...)`, plus the release build contract. Record Go 1.26.5 in the acceptance report; offline verification must not download a toolchain. - [ ] **Step 2: Run complete backend gates:** `cd backend && npx vitest run && npx tsc --noEmit -p . && npm run build`. - [ ] **Step 3: Run focused harness tests listed in the target map, then full `.venv/bin/pytest -q` if feasible.** Any baseline failure must be reported exactly; touched Python files must be Ruff-clean. - [ ] **Step 4: Run all affected Compose/security contracts** from Task 11 and `git diff --check`. - [ ] **Step 5: Run exactly one final clean P2 integration at the final source commit:** ```bash ./scripts/p2-acceptance.sh integration --keep ``` Expected final lines: ```text P2 automated integration: PASS P2 manual acceptance: PENDING ``` - [ ] **Step 6: Verify report hashes, declared artifacts, secret scan, closed listeners, no maintenance container, and exact cleanup/retention.** - [ ] **Step 7: Update and commit only tracked project state** with retained report path and truthful scope: ```bash git add PROJECT_STATE.md git commit -m "docs: record P2 automated acceptance" ``` - [ ] **Step 8: Report separate statuses and STOP:** ```text P2 automated integration: PASS — P2 manual acceptance: PENDING — docs/testing/p2-p6-manual-verification.md#p2 P3 authorization: PENDING — awaiting explicit user decision ``` Do not begin P3, mark manual PASS, or infer implementation approval from plan approval or automated evidence. --- ## Requirement traceability | Requirement/decision | P2 implementation/proof | Deferred truth | |---|---|---| | D2 / P2 / RNF7 | Native `thothctl`, dedicated one-shot service, existing engine | GUI/backend endpoint excluded | | RF1.1 / RF1.5 | Consume P1's complete source contract and bind descriptor/source to the same bootstrapped or active commit | Filesystem object materialization P6 | | RF1.2 / RNF1 | File-only bindings, operation-specific mounts, redaction/scan | No secret in Git/rendered output | | RF1.3 / RNF5 | Same binding+renderer code and byte-equivalence test | P3 canonical cross-revision identity/migration | | RF1.4 / RF2.2 | REST process goal; direct regression | SSH operational support P10 | | RF2.1 | DWH command, JSON, outer+child resume | — | | RF2.3 | Physical/LSH production then schema-index consumption | — | | RF2.4 / RNF2 | Immutable engine generations, unchanged rerun, ACTIVE preservation | Cross-revision DWH reuse P3 | | RF3.1 | JSON candidates/check, bounded SQL ingress, explicit digest review | Git-canonical review/sync P5 | | RF3.2 / D5 | Runtime-local P2 annotations only | Repository annotations and pinned sync P5 | | RF3.3 | No model-derived FK path added | Existing workflow invariant preserved | | RF4.1 | Existing schema hash/upsert + JSON counts | Collection lifecycle P4 | | RF5.1–RF5.2 | Consume P1 HTTP declaration for dry/run/resume/publish; parse/render filesystem and stop stably | Filesystem materialization P6; broader S3 policy separately reviewed | | RF5.3 / D9 | Prove the existing engine retention behavior during HTTP mutation without changing policy/defaults | New configurable or long-term GC behavior P9 | | RF5.4 | Assert corpus ACTIVE and every Evidence write/search identity belong to the selected workspace | Revision-scoped Evidence payload/read filtering P3 | | RNF3 | Safe errors, prior ACTIVE preserved, no-Evidence warning | — | | RNF4 | Workspace binding + P2 different-revision session guard | Revision-scoped points/roots P3 | | P2→P3 root-lock handoff | Sole factory-produced retained root-FD lease backed by the repo-owned Node-API v8 `workspace-fs-at` seam; exact `LockFileName` admits `writer.lock` and `session-readers.lock` at `0600`; typed `flockOwnedLock` owns all `fs-ext.flockSync` SH/EX blocking/nonblocking calls; numeric borrowing remains module-private to flock/child duplication; exact `*at`/directory-fsync ownership and Linux/Darwin negative/race tests; consumed writer API, transfer/borrow/close and replacement races | Reader-gate acquisition is exercised in P3 through this frozen seam, with no cast/raw binding/path reopen | | P2→P3 registry handoff | Repository-first immutable base/target plan, complete lexical capability set, participant no-reentry, atomic/reconciled active publication, and one repository-locked bounded `ensureBootstrapAddressed` recovery path for inspect/lazy list/status; locked active recheck returns `already_active`, corrupt active fails closed, and queued callers converge without a second scan/network/publication | Public `registry pull` command released in P3 | | RF8.1 / RF8.3–8.6 / RNF8–9 | Local/server manuals, P2 product-path smoke, retained report/scan/cleanup, separate walkthrough, and sessions-volume backup inventory | Aggregate P2–P6 smoke after P6; `.tht-dwh` ownership chapter P3 | | PRD AC2 | Native release binary on local/server installation profiles | Windows Docker is a separate manual claim | | PRD AC8 | HTTP unchanged/mutation cases | Filesystem change/GC P6/P9 | ## Explicit exclusions - No frontend/GUI or backend HTTP preprocessing endpoint. - No host Python, Node, Pi, `tht`, arbitrary shell, arbitrary entrypoint, or arbitrary host mount. - No released host Git pull/push or authoring command in P2. P2 does implement and process-test the internal repository-first `publishAddressed` publication boundary required by P3; only P3 exposes it. - No P3 canonical effective fingerprint, ownership migration, revision-scoped roots/points, or `.tht-dwh` operator chapter. - No P4 collection creation/index repair/rebuild/maintenance drain. - No P5 Git-canonical FK annotation sync/acceptance. - No P6 filesystem Evidence materialization, realpath/symlink containment, or pinned-tree retention. - No PSD migration/re-embedding (P7), final search/session/L2 gate (P8), policy-driven long-term GC (P9), or SSH runtime (P10). - No changed embedding model/dimensions/distance, external vector service, pgvector compatibility path, or NL→SQL workflow change. ## Execution notes - Every implementation task is RED → minimal GREEN → focused verification → commit. - Never weaken an existing P1 security invariant to simplify P2. - P2's session-inventory guard and deterministic same-revision config path are deliberate temporary safety mechanisms, not substitutes for P3. - Keep automated FK mechanics distinct from human review and from the later P5 Git decision. - A passed plan review authorizes only plan acceptance. Implementation starts only after the user's separate explicit approval.