141 KiB
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:
- The only public host interface is the installed native
thothctlbinary. Docker/Compose is required, but host Python, Node, Pi,tht, and a running Fastify backend are not. workspace inspectconsumes the active validated snapshot or, only when the repository-locked recheck proves no active state exists, submits theregistry_bootstrapvariant to the same addressed publication owner used by pull, lazy list, status, and author-publication activation. A queued caller that observed earlier absence returnsalready_activewith 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.- Operator and session configuration use the same
resolveRuntimeBindingsandrenderRuntimeConfigimplementation. 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, setsvectors.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. - 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.
- 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 checkalone is not treated as human approval. - 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. - P2 never creates, repairs, deletes, or rebuilds a Qdrant collection. Schema/Evidence writes require an already-existing, exactly compatible collection and a harness
require_existingmode that cannot race into auto-create. P4 owns lifecycle reconciliation. - 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.
- Filesystem Evidence is rendered but execution stops before discovery with
evidence_materialization_requiredand no partial corpus/vector publication. P6 owns materialization and symlink/containment checks. postgres_directandrest_apirouting remain supported and are regression-tested; the clean P2 process goal uses controlled REST.ssh_tunnelreturns a stable fail-closed result until P10.- 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.
- 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
thothctl --installation <absolute>/thothii-installation.yaml workspace inspect
--workspace <id> [--json]
thothctl ... workspace preprocess dwh
--workspace <id> [--resume <outer-run-id>] [--json]
thothctl ... workspace schema suggest-fks
--workspace <id>
[--from-sql <regular-file>]... [--assume <column=table>]...
[--output <new-file>] [--json]
thothctl ... workspace schema check
--workspace <id> --resume <32-hex-outer-run-id>
[--annotations <regular-file> --reviewed-candidates <sha256:hex>]
[--json]
thothctl ... workspace index-schema
--workspace <id> [--json]
thothctl ... workspace preprocess evidence
--workspace <id> [--dry-run] [--resume <outer-run-id>] [--json]
thothctl ... workspace preprocess run
--workspace <id> [--resume <outer-run-id>] [--json]
Rules:
--workspaceoccurs 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 checkalways requires the exact--resume <32-hex-outer-run-id>returned byschema suggest-fksor the blocked full run; the candidate digest is not a run selector and no automatic digest lookup is permitted. - At most 32
--from-sqlfiles, 1 MiB each and 16 MiB total.thothctlopens 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. --assumeoccurs at most 256 times; each value is at most 256 bytes and is validated before Compose.--annotationsis a single UTF-8 YAML file, at most 16 MiB.--reviewed-candidatesis mandatory with it and must equal the persisted candidate artifact digest. The pair is invalid without both flags.--outputis 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 --writeis intentionally not exposed: an automatic merge is not a human review decision. - Bootstrap recovery is automatic and installation-scoped;
workspace inspectdeliberately has no public bootstrap--resumeselector. Inspect, lazy list, and status all invoke the same boundedensureBootstrapAddressedselector underrepository.lock; it rechecks valid/corrupt/absent active state before any job scan or bootstrap network operation, so queued stale-absence callers returnalready_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.
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<string, string>;
completedStages: string[];
counts?: Record<string, number>;
artifactIdentities?: Array<{ kind: string; digest: string }>;
warnings?: string[];
}
- Exit
0:succeeded,unchanged, ordry_run. - Exit
3: expected operator checkpoint/block (manual_review_required,evidence_materialization_required, lock/revision conflict, orregistry_bootstrap_recovery_conflict). The Go JSON encoder preserves that exact code; human mode prints onlyBootstrap 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
409with{ "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
/data/sessions/<workspace-id>/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.tsis the sole producer of an exported, non-forgeableVerifiedWorkspaceLockRootLease. Its factory is bound at construction to the installation-derived sessions root, accepts only a factory-producedCanonicalWorkspaceLockRootInputfor 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 failspreprocessing_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.acquireOrProvisionmay create it, and only while the caller already holdsrepository.lock. The factory retains and verifies the installation sessions-parent FD, takes its private parent provisioning lock, uses the repo-ownedworkspace-fs-atNode-API seam for literalmkdirat(parentFd, workspaceId, 0700)plusopenat(..., O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC), requires the exact service UID/mode, fsyncs the new leaf and verified parent, and handlesEEXISTonly 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 useacquire; registry changed-set acquisition usesacquireOrProvisionfor 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.gypwithNAPI_VERSION=8, type declarationbackend/src/native/workspace-fs-at-binding.d.ts, sole TypeScript wrapperbackend/src/workspaces/workspace-fs-at.ts, and build driverbackend/scripts/build-workspace-fs-at.mjs. It exposes only typedopenat,mkdirat, no-followfstatat, directoryfsync, explicitclose, the closed lock-file name union, and typed owned-handle flock/child-stdio operations. The raw numeric FD borrow is module-private toworkspace-fs-at.tsand is authorized only insideflockOwnedLockandduplicateForChildStdio; 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.jsonandbackend/package-lock.jsonpinnode-gyp@11.2.0, require Node 22, and build the addon from committed source; no downloaded/prebuilt addon is accepted. writer.lockand the P3 handoff filesession-readers.lockare the exactLockFileNamevalues. Each is a regular0600file opened relative to the retained root FD byworkspace-fs-atwithO_RDWR|O_CREAT|O_NOFOLLOW|O_CLOEXECand verified through itsfstatat/owned-FD stat result as a single-link regular file owned by the service UID.flockOwnedLockis the only TypeScript locking boundary: it synchronously borrows the owned regular-file FD inside the wrapper module and calls exactfs-ext@2.1.1.flockSyncwith the typed shared/exclusive plus blocking/nonblocking combination (sh,ex,shnb, orexnb). 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-extis never credited withopenat,mkdirat,fstatat, directory fsync, or close.runUnderWorkspaceWriterLockconsumes one root lease. The sole multi-lock producer isrunUnderOrderedWorkspaceWriterLocks(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 untilaction(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 exactlysession-readers.lockat0600through its retained root and the privateWorkspaceFsAtV1instance, callsflockOwnedLock(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 aWorkspaceSessionReadersLockLeaseowning 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. Owningclose()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 stablepreprocessing_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 privateWorkspaceFsAtV1opens exactlysession-readers.lockat0600, then callsflockOwnedLock(owned, "exclusive", "nonblocking"). It rejects acquisition while a child spawn is already in flight and rejects nested/concurrent reader-gate acquisition; whileactionis live, the same capability's closed-unionspawnChildremains usable and the reader lock is held through that child settlement. Contention or open/flock failure closes any opened lock once and never callsaction. On success it passes only a private-constructorBorrowedWorkspaceSessionReadersExclusiveLockLeasecontaining diagnostic workspace/root identities andassertLive()—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 stablepreprocessing_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 failassertLive()after settlement. Registry quiescence owners must call this operation rather than constructBorrowedWorkspaceSessionReadersExclusiveLockLeaseor 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 switchedWorkspaceLockedChildRequestunion already validated by the coordinator; P3, P5, and P6 extend that same union with exact variants and no raw path/argv member. Pythonrequire_workspace_writer_lock(cfg)first validates FD 4 as the expected retained root directory, openspreprocessing/writer.lockrelative to FD 4 with no-follow semantics, proves it is the same device/inode as FD 3, and callsfcntl.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 returnspreprocessing_conflictbefore 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 →runUnderOrderedWorkspaceWriterLocksfor the complete changed set → each changed workspace's quiescence andrunUnderSessionReadersExclusivecallback → 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_sourceidentity. 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-identicalconfig_dwh_binding()output. Same path + different bytes returnseffective_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 toWorkspaceRegistry.ensureBootstrapAddressed; while continuously holdingrepository.lock, that one method first revalidates active state, returnsalready_activeor 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 bypublishAddressed. Pull, author-publication activation, and internal active-pointer change delegate toWorkspaceRegistry.publishAddressed; both paths use the same callback-scopedCapabilityAwareRegistryPublicationLifecycleOwner, and no oldbootstrap,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, andterminal_durablepersistence 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 <owned-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 asegress.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 bothcoreandworkspace-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; modifydocker/core.Dockerfilewith 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.yamlas 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.tsto 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, andbackend/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 sharedsession-readers.lockowner, consuming only the typedworkspace-fs-atwrapper. - Create
backend/src/workspaces/preprocessing-state.ts: state schema, durable writes, writer capability, the P2-owned callback-scoped exclusivesession-readers.lockoperation, inherited-FD verification contract, and resume reconciliation. Exactfs-ext@2.1.1remains imported only byworkspace-fs-at.tsfor typed kernelflock. - 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.tsandbackend/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, andbackend/test/fixtures/workspace-registry-addressed-worker.mjs. - Modify
backend/package.jsonandbackend/package-lock.json: pinnode-gyp@11.2.0, exactfs-ext@2.1.1forflock(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.mdP2 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.mjsonly if needed to generate the isolated walkthrough lab; automation must never create PASS. - Update
PROJECT_STATE.mdonly 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.
// 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.
// 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<void>;
}
export class VerifiedWorkspaceLockRootLease {
private constructor();
readonly identity: WorkspaceLockRootIdentityV1;
borrow<T>(action: (borrowed: BorrowedVerifiedWorkspaceLockRootLease) => Promise<T>): Promise<T>;
acquireSessionReadersShared(): Promise<WorkspaceSessionReadersLockLease>;
transfer(): VerifiedWorkspaceLockRootLease;
close(): Promise<void>;
}
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<VerifiedWorkspaceLockRootLease>;
acquireOrProvision(
input: CanonicalWorkspaceLockRootInput,
): Promise<VerifiedWorkspaceLockRootLease>;
}
// 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<PreprocessingRunStateV1>;
loadForResume(input: ResumeRunInput): Promise<PreprocessingRunStateV1>;
transition(runId: string, transition: RunTransition): Promise<PreprocessingRunStateV1>;
writeFkCandidate(runId: string, yaml: Uint8Array): Promise<ArtifactIdentity>;
recordFkReview(runId: string, review: FkReviewInput): Promise<FkReviewRecordV1>;
}
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<T>(
action: (lease: BorrowedWorkspaceSessionReadersExclusiveLockLease) => Promise<T>,
): Promise<T>;
spawnChild(request: WorkspaceLockedChildRequest): Promise<WorkspaceLockedChildResult>;
}
export interface BorrowedOrderedWorkspaceWriterLeaseV1 {
readonly workspaceId: CanonicalWorkspaceId;
readonly rootLease: BorrowedVerifiedWorkspaceLockRootLease;
readonly writerCapability: WorkspaceWriterLockCapability;
}
export class OrderedWorkspaceWriterCapabilitySet {
private constructor();
readonly workspaceIds: readonly CanonicalWorkspaceId[];
forWorkspace<T>(
workspaceId: CanonicalWorkspaceId,
action: (lease: BorrowedOrderedWorkspaceWriterLeaseV1) => Promise<T>,
): Promise<T>;
forEachWorkspace<T>(
action: (lease: BorrowedOrderedWorkspaceWriterLeaseV1) => Promise<T>,
): Promise<readonly T[]>;
}
export function runUnderOrderedWorkspaceWriterLocks<T>(
rootLeases: readonly VerifiedWorkspaceLockRootLease[],
action: (capabilities: OrderedWorkspaceWriterCapabilitySet) => Promise<T>,
): Promise<T>;
export function runUnderWorkspaceWriterLock<T>(
rootLease: VerifiedWorkspaceLockRootLease,
action: (capability: WorkspaceWriterLockCapability) => Promise<T>,
): Promise<T>;
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<T> {
readonly participantId: string;
prepare(
plan: RegistryAddressedPlanV1,
workspace: AddressedWorkspacePublicationLeaseV1,
): Promise<T>;
reconcile(
plan: RegistryAddressedPlanV1,
workspace: AddressedWorkspacePublicationLeaseV1,
prepared: T,
phase: RegistryAddressedPublicationPhaseV1,
): Promise<void>;
}
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<RegistrySynchronizerPreparedV1>;
}
export class CapabilityAwareRegistryPublicationLifecycleOwner {
constructor();
run<T>(input: {
readonly plan: RegistryAddressedPlanV1;
readonly capabilities: OrderedWorkspaceWriterCapabilitySet;
readonly participants: readonly CapabilityAwareRegistryPublicationParticipant<unknown>[];
readonly synchronizers: readonly CapabilityAwareRegistryPublicationSynchronizer[];
readonly action: () => Promise<T>;
}): Promise<T>;
}
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<unknown>[];
readonly synchronizers: readonly CapabilityAwareRegistryPublicationSynchronizer[];
});
ensureBootstrapAddressed(
identity: RegistryBootstrapRecoveryIdentityV1,
): Promise<RegistryEnsureBootstrapAddressedResultV1>;
publishAddressed(request: RegistryAddressedRequestV1): Promise<RegistryAddressedResultV1>;
}
// backend/src/workspaces/preprocessing-service.ts
export class WorkspacePreprocessingService {
execute(command: MaintenanceCommand, ingress: MaintenanceIngress): Promise<WorkspaceOperationResult>;
}
// backend/src/workspace-maintenance.ts
export async function main(
argv: readonly string[], stdin: NodeJS.ReadableStream,
stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream,
): Promise<number>;
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 fstats 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/<run-id>/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:
# 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.goand 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' -vand verify the new tests fail becauseworkspaceis unknown. -
Step 3: Add closed request types (
InspectCommandexactly 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.RunBoundedas the frozen streaming API above and requireworkspaceops.Runto 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:
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, andvector index-schema --jsonto 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/3still usesruntime_identity.workspace_id, never the config basename. -
Step 4: Run:
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:
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:
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.renderRuntimeConfigemits it for all schema-v3 registry configs, whether acquired by a session or maintenance. Only configs without registryruntime_identityretain 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:
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:
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
ThtRunnerinto one explicit-input component. - Step 5: Preserve session semantics but replace the random source path:
ThtRunner.acquireWorkspaceRuntimedelegates to the component and opens the same verified deterministic/data/sessions/<id>/preprocessing/runtime-config/<revision>.yamlpublication used by maintenance. It may retain an opaque FD for TOCTOU resistance, but the harness-visible-csource 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/<id>/preprocessing/runtime-config/<revision>.yamlplus a manifest, mode0400/0600. Do not add any operator-only rendered field:collection_lifecycle: require_existingandegress.http_private_host_allowlistare 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:
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.jsonandbackend/package-lock.json(Node 22 engine, exactnode-gyp@11.2.0, exactfs-ext@2.1.1for 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.Dockerfileto compile the repo-owned addon in an explicit compiler stage, copy only its.nodeoutput 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, exactLockFileName = "writer.lock" | "session-readers.lock", and stable errno mapping. Prove both names open only as0600regular locks and every other name/mode is rejected. With realfs-ext@2.1.1, spy and contend onflockOwnedLockto 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,ELOOPor platform-equivalent no-follow refusal, andEBADF; 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 betweenfstatat,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. ProvecanonicalInputaccepts 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. ForacquireOrProvision, cover absent added and absent never-used removed workspace leaves, exact UID/0700, anchored no-followmkdirat/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 onlyPromise<WorkspaceSessionReadersLockLease>; that lease exposes only diagnosticrootIdentity,transfer(), andclose(). 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:
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 exactLockFileName,openOrCreateLockAt(..., 0o600), and typedflockOwnedLock; its module-private synchronous numeric borrow calls exactfs-ext@2.1.1.flockSyncfor the four typed modes. The only other authorized numeric-borrow consumer is module-privateduplicateForChildStdio, which exposes no FD. Pin exact lockfile inputs; do not usefs-extfor any filesystem-at operation. Make the root factory depend only onWorkspaceFsAtV1and 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. ImplementacquireSessionReadersShared()andWorkspaceSessionReadersLockLeaseexactly 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
flockOwnedLockwith"exclusive", "nonblocking"on the ownedwriter.lockhandle (and therefore the realfs-ext.flockSync, not a mock ownership marker). ProverunUnderOrderedWorkspaceWriterLocksitself—not nestedrunUnderWorkspaceWriterLockcalls—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-followwriter.lockidentity, type/link/mode/UID, and already-heldflock. 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 dedicatedworkspace-session-readers-lock.test.ts, call the production methods rather than a shadow factory. For session ownership, acquire a root, callacquireSessionReadersShared(), 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, childexit, childclose, 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<T>(action: (lease: BorrowedWorkspaceSessionReadersExclusiveLockLease) => Promise<T>): Promise<T>. Prove it invokes onlyopenOrCreateLockAt(retainedRoot, "session-readers.lock", 0o600)andflockOwnedLock(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-unionspawnChildand holds the reader lock through its teardown; invalidates a captured borrow before settlement soassertLive()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 stablepreprocessing_conflictwith no FD/path/name leak. Type/source fences allow only P2workspace-fs-at.tsto open/flock and reject raw addon/fs-extimports, 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
RegistryEnsureBootstrapAddressedResultV1signature, including exact{ kind: "already_active", snapshot }and{ kind: "bootstrap_terminal", result, snapshot }branches. Empty bootstrap hasbaseCommit/base digestnull, 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 heldrepository.lock, prove the active pointer/snapshot is rechecked before any job-directory open: valid active returnsalready_activewith 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 withregistry_bootstrap_recovery_conflict. Prove no mtime/newest/lexical-last/ remote-head heuristic is observable. Exercise inspect, status, and lazy list/bootstrap throughensureBootstrapAddressed; 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 throughpublishAddressed, including app dependency wiring. Prove no callable oldbootstrap,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
0600addressed-publication-jobs/<run-id>.jsonclaim and thatrequest_claimedrename+parent-fsync completes before any network. Cover kills before claim rename, after claim fsync, during/after advertisement but before durable pin, aftertarget_advertisedfsync, during/after exact-OID fetch, after immutable-ref creation but beforetarget_fetched, after its fsync, before/afterplanned, 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 aftertarget_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 onrepository.lock: prove exactly one run claim, advertisement/fetch, publication, and terminal job; queued callers returnalready_activebefore 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:
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 throughopenOrCreateLockAt(root, "writer.lock", 0o600)followed byflockOwnedLock(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-operandrunUnderSessionReadersExclusive(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.spawnChildaccepts 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, andplannedtransitions plus both exact plan variants. Implement the one bounded no-followensureBootstrapAddressedactive-recheck/scan/zero-create/sole-exact-resume/fail-closed selector while continuously holdingrepository.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 withpublishAddressedrather 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 callacquireOrProvisionwhile retainingrepository.lock, then pass all leases once torunUnderOrderedWorkspaceWriterLocks; keep quiescence, each exactwriterCapability.runUnderSessionReadersExclusive(...)callback, participants, synchronizers, atomic active publication/reconciliation, andterminal_durableinside 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.ensureBootstrapAddressedandpublishAddressed; 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 bothalready_activeandbootstrap_terminalto the returned exact snapshot; make pull and author activation delegate topublishAddressed; update routes andapp.tsconstruction. 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:
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:
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.tsonly 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
inspecttests 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 sameensureBootstrapAddressedspy as lazy list and status. Exercise both result branches: a queued stale-absence caller must consumealready_activeand the exact returned snapshot with no scan/fetch/pull, while bootstrap consumesbootstrap_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:
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
WorkspacePreprocessingServicedependency 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,
inspectderivesRegistryBootstrapRecoveryIdentityV1from the validated installation/repository/remote and callsWorkspaceRegistry.ensureBootstrapAddressed; it never constructs or selects a run ID. The selector rechecks active state under its retained repository lock, returns thealready_activesnapshot to stale queued callers, or creates/resumes and returns thebootstrap_terminalsnapshot; 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 returnworkspace_not_activatablewith safe instructions if the inspect/bootstrap prerequisite is absent. - Step 6: Implement bounded child execution with fixed executable/argv,
-cafter 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
inspectand result encoding. - Step 8: Run GREEN gates:
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 <verified-revision-config>, 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:
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 dwhunder 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_activatablewith a P10 warning. - Step 7: Run GREEN focused gates:
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_requiredwith its 32-hex outer run ID;schema checkwithout--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:
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-fkswith candidate count/digest and optional exclusive output. - Step 5: Implement
schema checkin two modes: read-only orphan validation; or reviewed annotation import requiring the exact candidate digest. Both modes require--resume <32-hex-outer-run-id>, callPreprocessingStateStore.loadForResumefor 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:
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.goand 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:
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_ALLOWLISTgrammar, session/operator rendered byte andconfig_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_allowlistidentically into session and maintenance YAML. The descriptor'sallow_private_hostsis 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.254and 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. Descriptorallow_private_hostsalone never bypasses any DNS or peer check. - Step 6: Implement
index-schemaandpreprocess evidencewith 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:
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 → evidenceand for no hidden/skipped mutation. Run:
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:
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;
coreand 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 throughRunBounded. -
Step 4: Run RED packaging contracts:
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.Runcan 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 configstructurally before run. - Step 7: Extend Pi update/rollback override generation so future selected images cannot split core and maintenance.
- Step 8: Run:
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 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 coderegistry_bootstrap_recovery_conflict, maps it to exit 3, and emits only the frozen safe human sentence. Run:
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:
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:
.gitignoreonly if the existing.artifacts/rule is insufficient
The public command is:
./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:
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-<run-id>/: local bare Git + author clone, an empty installation registry thatworkspace inspectbootstraps, 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
thothctlproduct commands and assert:- exact inspect revision/config identity;
- DWH introspection+LSH, clean-process rerun/resume, physical/LSH artifacts;
- FK pristine JSON, pause-before-index, candidate export, explicit digest review, resume;
- pre-curated full-run completion;
- schema index counts and unchanged rerun;
- HTTP Evidence dry-run, publish, unchanged rerun, input mutation/new generation/ACTIVE;
- no-Evidence warning/skip;
- filesystem
evidence_materialization_requiredwith no partial output; - direct renderer regression and SSH fail-closed result;
- missing workspace/binding, resume mismatch, different-revision resumable session, concurrent writer, annotation invalid, egress refusal (including redirect/rebinding), and semantic incompatibility;
- 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; - 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;
- no collection creation, no backend listener, no Pi init, no arbitrary mount;
- exact cleanup preserving all foreign/pre-existing resources;
- 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_activeresults, 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.jsonandreport.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:
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.mdP2 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.mdmust 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 returnsalready_activebefore 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.mdwith reviewer, UTC time, explicit result for every P2 check, observations, and exactlyP2 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 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 versionis 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 -qif 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:
./scripts/p2-acceptance.sh integration --keep
Expected final lines:
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:
git add PROJECT_STATE.md
git commit -m "docs: record P2 automated acceptance"
- Step 8: Report separate statuses and STOP:
P2 automated integration: PASS — <retained report>
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
publishAddressedpublication boundary required by P3; only P3 exposes it. - No P3 canonical effective fingerprint, ownership migration, revision-scoped roots/points, or
.tht-dwhoperator 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.