DossierX turns a directory of YAML "claims" — atomic, reviewable facts about a system — into a linted, cross-checked HTML site a human can actually read and argue with. Each claim can be locked once a human has approved it, after which it never changes silently: a drifted dependency, a code change that contradicts it, or an open comment thread flags it for review instead. It is built to be operated by an agent and reviewed by a human, and since v0.3.0 each of them has their own surface.
Spec Kit and OpenSpec keep a specification in the repository as a folder of markdown that the agent reads and writes, and they leave the question of whether the code still agrees with that specification to the agent reading it. DossierX is not another markdown folder, and it is not a chat wrapper: it has no model inside it, and an LLM is never the judge. The model drafts claims, tags and code; the ledger, the gates and the human decide.
What the engine actually enforces, each with the code it fails on:
| Mechanism | What it does | Refusal |
|---|---|---|
| Atomic YAML claims | one reviewable fact per file, with required rests_on edges the engine walks and lints |
lint_failed |
| The lock ledger | every approval records a hash of exactly what was approved, when, by whom, and the human's own words; a locked claim edited by hand, a deleted record, a status flipped in either direction is a named finding that fails check, and claim lock refuses rather than re-sign it |
integrity_failed |
| The constitution gate | one lockable constitution.yaml above every module — not a module, never a rests_on target; constitution lock --reason records its content hash and the human's words in the same lock store, and no claim locks, no claim reaudit --confirm applies and no plain check passes while the roof is missing, draft or edited since its lock |
CONSTITUTION_NOT_LOCKED (CONSTITUTION_OVER_CAP over 800 words) |
| Human Resolve | a claim cannot lock while it carries an open thread; the agent replies, only the human resolves, and --reason carries the human's words into the record |
unresolved_comments |
| Code links, machine-judged | check scans source for dossierx-claim: and dossierx-step: tags; with source_dirs set, a locked module claim with no link fails (unless it declares embodiment: {mode: none}), and a stepped claim must be tagged on every step |
unlinked_claims |
| Conformance | a claim's declared embodiment is compared against a project-owned observation file; with conformance.blocking: true an owed, mismatched or uncheckable check fails check |
conformance_failed |
| Exit codes, not opinions | one JSON envelope per run with a stable error.code; a run either exits 0 or names what stopped it |
— |
What it does not do is also stated: a code link proves a pointer exists, not that the code still means what the claim says. That question belongs to the project's tests, the conformance checks it declares, and the human's lock — never to a model's reading of the two.
| Agent — the operator | Human — the reviewer | |
|---|---|---|
| Surface | the CLI: 26 commands under 9 nouns, JSON by default | the viewer: dossierx serve, plus chat with the agent |
| Does | writes and restructures draft claims, links code, replies on threads, runs check, executes lifecycle actions you approved |
reads claims, comments on any card, resolves and reopens threads, says "lock it" |
| Cannot | change a locked claim without an approval on the record; resolve or reopen your threads; edit or delete comments — the last three refused outright on the CLI, and rules rather than walls on the viewer's localhost API | (nothing is prevented — you are the approver; you simply shouldn't need to type a DossierX command other than serve) |
The gate is narrower than "the agent may not touch claims", which would defeat the point. Draft claims are the agent's workshop and stay unfrictioned. The invariant is: nothing already locked changes without your approval on the record — see the lock ledger.
Paste this into Claude Code, Codex, or any other coding agent working in the repository you want documented. It is idempotent: running it again on a project that is already set up changes nothing and reports what it found.
Set up DossierX in this repository.
1. If the `dossierx` binary is missing, install it with
`go install github.com/BarterX-Tech/dossierx/cmd/dossierx@v0.7.23`,
then run `dossierx version` and show me the output.
2. If `project.config.yaml` and the claims directory do not exist yet,
propose a title and the modules, write `facets: [contract, internals]`
(engine-fixed; do not offer other facet names), and WAIT for me to confirm
before writing anything. Then write one `claims/<module>/manifest.yaml`
per module (a one-line `summary`, `provides: []`, `depends_on: []`) and
draft `constitution.yaml` beside the config. Show me the constitution;
once I approve it, run `dossierx constitution lock --reason "<my words>"`.
Step 6 cannot pass without the manifests and the locked constitution.
3. Run `dossierx skills export .claude/skills` — or point it at whichever
skills/instructions directory this harness actually reads. Run it AFTER
step 2, never before: the export finds the project root through
`project.config.yaml`, and only a rooted export maintains its section in
an `AGENTS.md` that already exists — run rootless it still exits 0, and
nothing later in this message exports again. Load what it wrote and follow it: those guides,
not this message, are the contract.
4. ASK ME before installing the git pre-commit hook. My answer decides the
hook alone, never CI — CI is the authority either way. If I say yes, fetch
https://raw.githubusercontent.com/BarterX-Tech/dossierx/v0.7.23/scripts/install-git-hook.sh
to a file, show me what it does, run `sh install-git-hook.sh --yes`, then
add the CI workflow as well. If I say no, skip the hook and
add the CI workflow alone, and tell me so. Either answer ends with the
workflow installed; the hook is only fast local feedback on top of it.
5. ONLY if `dossierx check` reports `lock-ledger-pre-ledger`, or a lock
or reaudit refuses with `pre_ledger_unadopted`: this
project locked claims before it had a lock ledger. There is NO migration
command. Crossing means unlocking every locked claim, then re-locking
only what I still stand behind.
Show me the plan and WAIT for my yes before unlocking anything. On a
project you created at step 2 this never fires — say you skipped it.
6. Run `dossierx check --format text` and show me the output. Do not tell me
it works; show me it exiting 0.
7. Tell me to commit `build/ledger/lock-store.json` — and
`build/ledger/comment-digest.json` and `build/ledger/flag-store.json` once they
appear — together with the claim files. All three are tracked artifacts:
the ledger is what CI compares the claims against, the gate is vacuous
without it, and a flag that does not travel with its claim reaudits to an
empty proposal on the next machine. Never add them to .gitignore.
8. Tell me to run `dossierx serve`. That is the only DossierX command I run.
Draft claims are yours: write, restructure and delete them freely, that is
the work. A LOCKED claim never changes without asking me first, and the path
is unlock -> fix -> lock:
dossierx claim unlock <id> --reason "<my words>"
...make the edit...
dossierx claim lock <id> --dry-run # show me this; it prints a snapshot
dossierx claim lock <id> --reason "<my words>" --proposal "<snapshot>"
Never hand-edit a locked claim's YAML, and never lock, unlock, flag or
reaudit anything without my explicit yes. `reaudit` is the drift tool, not
the general edit tool.
Installing the binary needs a Go 1.26+ toolchain; prebuilt binaries for common platforms are attached to each GitHub release. A DossierX project is a project.config.yaml plus a directory of claim YAML files — the config schema is below, and the agent will write both for you.
dossierx serveOpen the URL it prints. That is a local viewer of every claim, facet and code link, plus a live comment API: click 💬 on any card — including cards nobody has commented on yet — write what you doubt, and the page re-renders as claim files change on disk. It binds a random high port unless you pass --port, is localhost-only, and never writes build/viewer/index.html or build/catalog/catalog.json on a page load.
The review loop, end to end:
- You comment on a card in the viewer. The comment is written into that claim's YAML.
- You tell the agent "I left comments."
- The agent runs
dossierx comment inbox— every open thread in the project, one call. - The agent fixes the claim and replies on the thread. Replies are deliberately ungated: an agent may reply to a human-opened thread, because that is the entire workflow.
- You click Resolve. That click is the approval — and it is load-bearing, because a claim cannot be locked while it carries an unresolved thread.
- You say "good, lock it." The agent resolves which card you meant, previews with
--dry-run, waits for your yes, and runsdossierx claim lock <id> --reason "<your words>" --proposal "<the preview token>".
Closing a thread is yours alone on the CLI, and in v0.3.0 that is structural rather than polite: dossierx comment is inbox · list · add · reply and nothing else. Resolve, reopen, edit and delete were removed from the CLI in v0.3.0 and live only in the viewer and in dossierx serve's HTTP API.
How far that enforcement actually goes. Advisory rights — an actor may act only on its own messages — are enforced in the engine, and on the CLI the actor is whatever you passed as --as. On the CLI that is now structural rather than enforced: dossierx comment is inbox · list · add · reply, and none of those can close a thread at all — the verbs that could were removed in v0.3.0. An agent following its skills also never asserts --as human for something it decided. The viewer's write API is a different trust boundary. It takes the actor from the request body and treats a request that omits as as human, so any local caller that can reach dossierx serve gets full human rights: it can resolve, reopen, edit or delete your thread, and the record it leaves positively attests human. That is a deliberate choice, not an oversight — the server binds 127.0.0.1 and refuses cross-origin requests, but anything that can curl it can already open the claim's YAML in an editor, so a token on the API would move the lock rather than add one. Read the rule as: enforced for the CLI actor, and the same trust level as filesystem access for the viewer API. It is the operating rule of the review loop either way, and an agent that goes around it has forged the only approval signal in the design.
A static file:// export of the viewer is read-only by design — comments need dossierx serve.
Twenty-six leaf commands under nine nouns. This is a machine surface: a human is not expected to run any of it. Use dossierx <noun> --help for flags, and --format text when you want prose.
check lint, bounded projections, code-link scan and the ledger gate
--validate read-only: lint + ledger; conformance projections
when declared; writes nothing
--staged judge the git index — what the commit will actually
contain — instead of the worktree, writes nothing
brief list · show · lock · unlock · reaudit the briefs beside the claims; a brief by path or id (see FORMAT.md)
claim show · list · new · lock · unlock · flag · reaudit · link · recover-approved-content
comment inbox · list · add · reply
constitution show · lock the project-root constitution.yaml
manifest show · list module harness file; --isolation / --integration
serve the human's viewer + comment API
skills export [dir] write the embedded agent skills into a project
version version, commit, build date (also --version)
What to implement next is locked claims, module depends_on, and claim rests_on.
Every subcommand takes the global --config (a path to project.config.yaml; when omitted, DossierX searches upward from the current directory the way git finds .git) and --format json|text.
Upgrading from v0.2.x? Twelve commands were removed and four moved in v0.3.0, and v0.4.0 removed migrate outright — a project whose lock store predates the ledger now crosses onto it by holding nothing locked; see Upgrading a pre-ledger project below and the CHANGELOG's full migration table.
--format json is the default, and every run emits exactly one envelope:
{"ok": false, "command": "claim lock", "stopped_at": "lint",
"error": {"code": "lint_failed", "message": "...", "hint": "run: dossierx check"}}error.code is a stable snake_case token — lint_failed, claim_not_found, rights_denied, integrity_failed, unresolved_comments, pre_ledger_unadopted, and so on. Branch on it. message and hint are prose and will be reworded; code is the promise. Successful runs carry their payload in data, non-blocking findings in warnings, and check reports how far it got in stopped_at.
The mutating claim, comment and constitution commands take --dry-run, which reports what would change and writes nothing. A dry run fails only when it cannot compute the preview: a refusal — including a missing required flag — is a successful blocked report (exit 0, ok: true, data.blocked: true). check --validate is the write-free check, and skills export --check compares instead of writing (exit 1, skills_drift, when anything differs).
--reason is required on claim lock, claim unlock, claim reaudit --confirm, claim flag, claim recover-approved-content and constitution lock. claim lock also requires --proposal, the snapshot its --dry-run returned for the same ids. Under the two-role split the human never types these, so --reason is where their approving words enter the record.
| Code | Meaning |
|---|---|
0 |
Success. |
1 |
Failure — a lint error, an integrity finding, a validation failure, a malformed claim, a failed write. |
2 |
Not found / not in the right state — e.g. no project.config.yaml found, a claim id that doesn't exist, or a claim that isn't review_pending when a command requires it to be. |
There is no fourth status and the three have not been renumbered. The fine-grained signal is error.code, not the exit status.
Claims are YAML in git, so nothing can prevent an edit. The goal is that no out-of-band edit of a locked claim is silent.
DossierX detects; the forge enforces. Keep that division in view for everything below. The ledger's job is to turn a silent edit into a named, recoverable finding — a stable rule string, the claim it is about, and the command that puts things back. What makes anyone obey that finding is branch protection with a required CI check, and that is exactly the point: the ledger is what makes a red check mean "widget.contract.overview changed without an approval, restore it from version control" instead of "something is off somewhere". A gate nobody can name the failure of is a gate people learn to re-run.
Every legitimate approval — claim lock and a confirmed claim reaudit — writes a record into the lock ledger: the hash of exactly what was approved, when, by which account, and the human's own --reason words. Unlocking marks the record released rather than deleting it, so the evidence that a claim was ever locked survives. Comment history gets the same treatment in its own digest store, which is why serve never needs write access to the lock store.
Three files hold the review state, under the build directory (build/ledger/, beside project.config.yaml's own build/; see Where DossierX writes):
| File | Holds |
|---|---|
build/ledger/lock-store.json |
the lock ledger — per locked claim: {hash, at, actor, reason} |
build/ledger/comment-digest.json |
the review history's fingerprint |
build/ledger/flag-store.json |
the pending claim flag triggers: each flagged claim's {claim_says, now_does, reason, flagged_at}, parked until a confirmed claim reaudit consumes it |
All three are tracked artifacts. Commit them; never .gitignore them — the lock store and the digest the moment anything is locked (the first lock creates build/ledger/comment-digest.json in the same act, before anyone has commented — stage them together or check --staged reports comment-digest-absent), the flag store the moment anything is flagged. A claim and its approval have to travel in the same commit for CI to be able to check either one: CI compares the claims against the ledger, so without the ledger in the repository the gate has nothing to compare against and is theatre. DossierX says so out loud rather than passing quietly: locked claims with no ledger is a hard error (lock-ledger-absent), and a ledger that exists but will not parse is lock-ledger-unreadable, never a silent skip. Neither one ever grandfathers its way to a pass, and since v0.4.0 there is no adoption path to reach for at all — restore the store from version control. A store that genuinely predates the ledger is a different state with a different recovery.
The flag store is not part of the gate — nothing compares it to anything — but it is not optional either. claim flag writes the before/after there and sets review_pending on the claim; claim reaudit reads it back to produce the diff it asks you to confirm. If the claim travels to another machine and the flag store does not, that claim arrives review_pending with nothing to propose, and confirming the empty proposal clears the flag having changed nothing.
That makes it the one store with no integrity coverage in either direction: deleting it, .gitignore-ing it, or emptying its map is silent — check still exits 0, and your recorded "the claim says X, the code does Y" is gone with nothing in the report to say so. It is a bounded hole (a flag is a request for review, not an approval, so erasing one cannot make a locked claim change or an unapproved claim read as approved) but it is a real one, and the mitigation is procedural until a rule covers it: commit the store with the claim it describes, and read an empty reaudit proposal on a review_pending claim as a missing flag entry rather than as "nothing to change". FORMAT.md states the same thing next to the findings that do exist.
These are the exception, not the rule. build/catalog/catalog.json, build/viewer/, and optional build/conformance/status.json are generated — regenerated in full by every dossierx check (the conformance status only when a claim declares an embodiment) — and are safe to .gitignore.
The gate names each disagreement:
| Finding | What it caught |
|---|---|
lock-ledger-pre-ledger |
project-scoped, and still the one benign entry in this table: this project's lock store predates the lock ledger, so nothing locked here has an approval record and there is nothing to judge against yet. Said once, with the crossing that clears it, rather than as one lock-ledger-missing per claim — whose recovery ("set it back to draft and re-lock") would be actively destructive advice here. New in v0.4.0, and the part not to miss: it is CONDITIONAL — silent unless the project actually holds a locked claim, because a pre-ledger project with nothing locked is correct state and crosses onto the ledger on its next lock, and a finding on correct state is how gates get switched off. Its write-path twin is error.code: pre_ledger_unadopted, which claim lock and claim reaudit --confirm refuse with in the same state. The recovery is the crossing, not a migration — v0.4.0 removed dossierx migrate entirely. Told apart from lock-ledger-absent by the store file itself, with no history needed: pre-ledger means the store is there and still on the pre-ledger schema, absent means the file is gone |
lock-ledger-missing |
a claim is locked with no approval record — e.g. status: draft flipped to locked by hand, walking past the lint, hub-gating and unresolved-comment gates |
lock-ledger-deleted |
lock-ledger-missing's sharper twin: a claim this engine locked, whose record is gone. Every other rule keys on a record existing, so deleting one took the claim out of the switch entirely — delete its ledger entry, flip status: locked back to draft, and it is an ordinary draft again, freely editable and re-lockable afterwards with an agent-supplied --reason that produces a record indistinguishable from a human's. check --validate reported ok: true with zero findings. The evidence the deletion does not reach sits one key away in the same file: locked_at, which every lock stamps and which nothing removes (unlock keeps the record and stamps released_at), plus the claim's dependency baselines under hashes. A record that is absent rather than released was deleted by hand. claim lock refuses this state outright (integrity_failed) — otherwise the last step of the bypass is the tool's own command: re-locking writes a fresh record over the rewritten content and the finding disappears for good |
lock-ledger-downgraded |
the lock store says it predates the ledger while the project proves it does not — its own version field set back to 1 and the ledger key deleted, one edit inside the audited file. This used to be the highest-value edit in the design, because adoption ran automatically and a store that claimed to predate the ledger was re-adopted on sight: the claims as they are now became the approved baseline. Adoption no longer exists at all (see the upgrade section), so the edit no longer buys approval — but a store lying about its own version is still a tampered store, and it is still reported. Restore it from version control; do not re-lock, and do not try to force the pre-ledger crossing by unlocking everything — both record the current bytes as the baseline, which is exactly what the downgrade was trying to achieve |
lock-ledger-released |
a claim is locked on a record an unlock already released — lock, unlock, then hand-edit status: back. A released record is a withdrawn approval, not a standing one, and the content hash cannot see it because the hash excludes status |
lock-content-drift |
a locked claim's content no longer matches what was approved — including fields the dependency-drift hash does not cover, such as raw_html_reviewed, section and order. (raw_html joined the dependency-drift hash in v0.4.1 so an edited attachment marks dependents stale; the ledger signs it either way) |
lock-ledger-orphan |
a draft claim still holding an unreleased record — locked flipped back to draft to dodge review |
lock-ledger-abandoned |
a locked claim's file was deleted while its approval record still stands. There is no claim delete verb, so rm was the one change to a locked claim that no rule saw: every other finding starts from the claims that exist. Unlock first, then delete |
comment-ledger-drift |
a review thread edited or deleted outside the engine |
comment-digest-absent |
build/ledger/comment-digest.json is gone from a ledger-covered project, so the rule above is checking nothing at all. Deleting the store was how an edited-away thread stopped being reported: a claim the store has never seen is unknown, never drifted. Restore the file from version control — re-creating it records whatever the claims say now — or git add it if this commit is the one that created it. Coverage is the only trigger, so deleting a claim's last thread and the store together no longer buys silence, and an upgrade still never trips it |
comment-digest-missing |
the store is there but a claim holding a standing approval has no entry in it — the map was emptied rather than the file deleted, which is cheaper to miss in a diff. Every approval records the claim's comment digest in the same act it records the approval, so a standing record with no entry is a statement about the store. Restore the file from version control, or git add it if this is the commit that updated it — do not run a comment op to re-create the entry, which records whatever the claim says now |
comment-digest-unrecorded |
in a ledger-covered project, a claim holding comment threads with no entry beside them in the digest store. The map was protected against being emptied wholesale, not against losing one key: hand-forge a thread as resolved, then drop that claim's key, and comment-ledger-drift had nothing to compare against — the claim locked, and the next ordinary command re-adopted the forged block as truth. An edit smaller than the one it was catching cleared the gate the whole review loop rests on. The predicate is the threads themselves, which is what survives the tamper: the single code path that writes a thread records the claim's digest in the same act, so threads with no entry means either the entry was removed or the threads were never written by the engine. Deliberately silent where evidence is honestly absent — an uncovered project, an absent store (comment-digest-absent says that once), and a claim with no threads at all. claim lock and every comment op refuse this state (integrity_failed / comment_digest_drift): an approval records the claim's comment digest in the same act, so locking here would manufacture the very evidence whose absence is the finding |
comment-digest-abandoned |
a digest entry that recorded review history whose claim id is no longer in the project — the rename launder: delete a claim's comments: block and change its id: in one edit and every rule that starts from the claim went quiet, because the old entry is the only thing the tamper could not reach. Silent for an entry that recorded no threads, and for a claim whose record an honest unlock released |
brief-content-drift |
a locked brief's summary, rests_on or body no longer matches what brief lock approved, or an image it references changed or joined or left the set (claim_id is the brief's path). The approved text is on the record, and the brief's page in the viewer shows the edit against it (Changes / Approved / Current); restore the file from version control or, on the human's yes to the edit, brief unlock → fix → brief lock — never a lock to make the finding go away |
brief-dependency-drift |
a locked brief's rests_on claim moved since the brief's baseline (claim_id is the brief's path). The brief is review_pending; its page shows one banner per changed claim with that claim's wording then and now. A warning: check still passes, and claim locking is not blocked. Confirm in a thread; on the human's yes, brief reaudit --confirm |
brief-unrecorded |
a brief says status: locked with no standing approval record — none, or one an unlock released. A hand-typed status approves nothing. When an older dossierx rewrote the store and dropped every brief record (version 4, no briefs map), the finding says to restore the store from before that write, not to re-lock |
brief-orphan |
a draft brief still holding a standing approval record — locked flipped to draft by hand, freeing the brief for edits nobody approved (brief unlock releases the record first) |
brief-abandoned |
a standing brief approval whose brief file was deleted or renamed. Unlock first, then delete |
store-gitignored |
a path the engine writes under build/ledger or build/code-links is matched by .gitignore and not in the index — the ordinary build/ pattern a Gradle, CMake or Node template ships. The approval would never reach the repository: a collaborator or CI clones a project with no ledger, check fails there with lock-ledger-absent, and every flag vanishes on the next clone. Checked per FILE with git check-ignore --no-index (a directory check goes green the moment one file under it is force-added), one finding per path. A path that is ignored but already tracked is a warnings[] line, not a finding. The recovery is the replacement block under Where DossierX writes, or build_dir pointed at a directory the pattern does not match. claim lock, claim flag and claim reaudit --confirm refuse the same state with error.code: store_gitignored (--dry-run previews it as the stores_are_tracked precondition), and refuse too when the project is inside a git work tree and git cannot answer; check, check --validate and check --staged then report data.gitignore_check instead of a verdict and exit 0 |
Every file the engine generates lives under one directory, build_dir (default build, resolved against the config file's directory like claims_dir; it may not sit inside claims_dir, contain it, or be the config directory itself), one subdirectory per kind:
| Kind | Path | Committed |
|---|---|---|
| code links, per module | build/code-links/<module>.json |
yes |
| lock ledger (claim approvals, the constitution's lock record, brief approvals) | build/ledger/lock-store.json |
yes |
| comment digest | build/ledger/comment-digest.json |
yes |
| flag store | build/ledger/flag-store.json |
yes |
| catalog | build/catalog/catalog.json |
no, regenerated by check |
| conformance | build/conformance/status.json |
no, generated by check when declared |
| conformance ownership | build/conformance/.dossierx-owned |
no, private cleanup marker |
| viewer | build/viewer/index.html |
no, regenerated by check |
| claims write sentinel | build/ledger/claims.lock |
never |
| build gitignore | build/.gitignore |
yes |
check writes build/.gitignore with the historical generated rules (catalog/, viewer/, *.lock, *.tmp-*, *.probe-*). An opted-in conformance run adds conformance/; the transition back to zero declarations restores the historical bytes. DossierX rewrites only those two exact engine-generated forms and never replaces a project-edited file. Stale conformance cleanup is authorized by the exact private .dossierx-owned marker beside status.json, not by the editable ignore file, so a custom build/.gitignore cannot strand stale status and a spoofed ignore rule cannot authorize deletion. A project whose repository .gitignore carries a bare build/ pattern — most Gradle, CMake, Python and Node templates do — would have every approval ignored, and git never re-enters an excluded directory, so !build/ledger/ alone does nothing. Replace the pattern with this block (build/* keeps the directory itself un-excluded; the slash-less negations work before the directories exist):
build/*
!build/.gitignore
!build/ledger
!build/ledger/*
!build/code-links
!build/code-links/*
or set build_dir to a directory the pattern does not match. A build_dir that resolves outside the repository has no gate: nothing written there is carried by the repository, and every verb says so in warnings[].
Migrating from a release that wrote to the project root. Every verb — check --validate and check --staged included — refuses with error.code: layout_legacy while any of the six legacy files (.dossierx-lock-store.json, .dossierx-comment-digest.json, .dossierx-flag-store.json, .implementation.<module>.json, .catalog.json, viewer/index.html) is at the project root, and prints the exact block to paste: one mkdir -p for the destination directories, git mv for each file git tracks, mv for each it does not, and a removal for the catalog and the viewer (regenerated). Signatures hash bytes, not paths, so nothing is re-locked; stage the moves in the same commit as any config change and run dossierx check --validate. The block is POSIX shell: on Windows paste it into Git Bash (the shell Git for Windows installs), not cmd.exe or PowerShell, and the refusal printed there says so.
The gate runs as the last step of check, after the catalog and viewer have been written: a tampered project still regenerates its documentation, it just does not exit 0. It is deliberately not a lint — one tampered claim must not be able to freeze locking project-wide.
dossierx check --stagedjudges the git index — what the commit will actually contain — reading content withgit show, never the worktree, and writing nothing. It reads both claim stores from the index,claims/andproject-claims/, alongside the ledger. This is what the pre-commit hook runs:scripts/install-git-hook.sh(withinstall-git-hook.ps1for PowerShell users). It asks before writing anything, resolvescore.hooksPathinstead of assuming.git/hooks, handles linked worktrees, never replaces a foreign hook without--force, and re-running it is a no-op.- CI is the authority. git does not run
pre-commitfor a clean merge, a rebase, a cherry-pick or a revert,--no-verifyis one keystroke away, and most contributors never installed the hook at all. Copyscripts/ci/dossierx-check.ymlinto your repository's.github/workflows/. If you adopt only one of the two, adopt this one — and make it a required status check on a protected branch, which is the step that turns a finding into something a merge has to satisfy. - The CI template runs plain
dossierx check, one step, and pins nofetch-depth. Every rule is evaluated against one tree, and a shallow checkout is a complete tree — soactions/checkout's default is all the job needs.--stagedis not wrong in CI, it is redundant: on a fresh checkout the index, the worktree andHEADare three names for the same tree, so it re-runs the same rules over the same bytes. Its home is the hook, where the index and the worktree genuinely differ.
Every rule above judges one tree: these claim files, this ledger, this digest store, exactly as they are. Within that evidence base, a change to a locked claim's approved content leaves a surviving artifact disagreeing with the one that moved, and that disagreement is caught. An agent that edits a locked claim, a careless hand-edit, a bad merge, a status flipped by hand in either direction, a deleted approval record, an erased comment thread — each one is a named finding (lock-content-drift, lock-ledger-missing, lock-ledger-orphan, lock-ledger-deleted, comment-ledger-drift, and the rest of the table above).
One field sits outside that: review_pending is engine-managed bookkeeping rather than approved content, so it is excluded from the locked-claim hash and hand-deleting it clears a review flag silently. FORMAT.md says where that line falls and why.
What it cannot see is the change that writes both sides at once, and the reason is one sentence rather than a list of cases:
An in-repo ledger cannot attest anything against the person who can write it.
A claim and its approval record are both tracked files, and both belong to whoever is committing. Edited together, in one commit, they agree — and nothing is left in the tree to disagree with. The sharpest form of it is not a deletion at all. Unlock a locked claim, rewrite its body, re-lock it — which mints a fresh record whose hash correctly covers the new content — then hand-edit that record's reason, at and actor back to the original approval's values. dossierx check and dossierx check --staged both report ok: true, over a ledger that now credits a human with approving something they never read. Nothing was erased; the ledger is complete, consistent and false. That is one illustration of the principle, not an inventory of the ways to reach it.
No in-repo mechanism closes this, because any evidence the tool consults lives in the repository and the repository is writable by the person being gated. An earlier build of v0.3.0 compared the staged tree against its parent commit; it was removed before release, because the parent commit is outside the commit but not outside the committer — a rebase, --orphan or a second config file switches such a comparison off without looking unusual — and because it refused ordinary git revert of a lock commit and audited a genuinely new monorepo project against a retired one's ledger. A control a rebase disables and a revert trips is not a control.
Where it is caught: the forge. DossierX detects; branch protection enforces. Put dossierx check behind a required status check on a protected branch and every rule above runs on the merge result. Then read the diff, because the change still has to arrive as one: a hand edit of a tracked JSON store whose entire purpose is to be read in a diff, sitting next to the claim change it was made to permit. In the example above the store half is a single line, and it has a signature worth knowing — the hash moved and the approval did not. Every honest lock writes hash, at, actor and reason in the same act, so a record whose content changed while its timestamp, actor and reason stood still did not come from a lock this engine performed. CODEOWNERS on the stores and the config makes that reading someone's job.
This is a designed boundary, not a to-do. FORMAT.md states it in full — what the tree can prove, what it cannot, why no file in the repository fixes that, and the one direction that would: signing the ledger with a key held outside the repository, of which git's own commit signing is the cheapest form.
No ceremony, and no flag, environment variable or config marker that exempts anything (an escape hatch on an integrity gate is itself the attack). Move the claim files, edit claims_dir:, stage the claims, the config and the unchanged stores together:
git mv claims docs/claims # take the claims with you
$EDITOR project.config.yaml # claims_dir: docs/claims
git add -A # the claims, the config AND the unchanged stores
dossierx check --staged # verify before committing
git commitIt passes because every locked claim is still reachable and still hashes to its existing record — the same thing the rules were reading all along. A move that strands locked claims fails from state alone, as lock-ledger-abandoned, once per claim.
The ledger is not authentication. actor is provenance, not identity, and anyone who can edit a claim can edit the ledger. What it buys is that tampering requires editing two tracked files consistently instead of one — and the second is a file whose entire purpose is to be read in the diff.
There is no migration command. v0.4.0 removed dossierx migrate --adopt outright, and nothing is grandfathered by any run, in any mode. A project whose locks predate the lock ledger crosses onto it by emptying itself of everything that predates it.
The state. A lock store written before the ledger existed — schema version: 1, no ledger key — fails check with the project-scoped lock-ledger-pre-ledger finding, and the approval-recording commands (claim lock, claim reaudit --confirm) refuse with error.code: pre_ledger_unadopted. Both happen only while the project still holds a locked claim. Hold nothing locked and both are silent.
The crossing. Unlock every locked claim, then re-lock only what you still stand behind. The first of those locks stamps the store onto the ledger.
# 1. every locked claim — unlock is gateless and always has been:
dossierx claim unlock <id> --reason "<your words>"
# 2. then re-lock only what you still stand behind:
dossierx claim lock <id> --dry-run
dossierx claim lock <id> --reason "<your words>" --proposal "<snapshot>"The first claim lock in a project holding nothing locked is what crosses it. That lock stamps the store onto the ledger schema and creates build/ledger/comment-digest.json in the same act — and it records a real approval, with your own words in --reason. That is the whole difference from the adoption path this replaces. Commit the rewritten build/ledger/lock-store.json and the new build/ledger/comment-digest.json together with the re-locks.
Why there is no command any more. v0.3.0 answered this state with dossierx migrate --adopt, and even then the reasoning was uncomfortable: adoption is the one operation that manufactures approval out of nothing. No predicate inside a project can tell an honest v0.2.x store from a downgraded one — locked_at shipped in v0.2.0 (git show v0.2.0:internal/lock/lock.go), so there is no field, no timestamp and no sibling file whose presence or absence distinguishes the two. And a migration you can re-run is a laundering command: delete one record, migrate again, and the edit it covered is re-signed as approved. v0.3.0 answered that with an already_migrated refusal; v0.4.0 answers it by having no command to re-run. The crossing above is safe from an ordinary write path for exactly that reason — an attacker who empties the project of every locked artifact to trigger it has destroyed the approvals they were trying to launder.
dossierx migrate survives as a hidden retired stub that fails naming this path. It has to exist as a command at all because cobra parses flags before any unknown-command handler runs: without it, dossierx migrate --adopt would fail as unknown flag: --adopt for every agent that still remembers the old instruction, rather than as an explanation.
A near neighbour worth telling apart, and the lock store itself is what distinguishes them — no git history required. lock-ledger-pre-ledger means the store is present, still on the pre-ledger schema, and something is still locked: benign, and the recovery is the crossing. lock-ledger-absent means the store file is gone while locked claims remain: tampering, and the recovery is version control.
Do not "fix" a ledger complaint by deleting the store or by re-locking. Both record the current bytes as approved with no diff shown to anyone.
Module manifests. Each configured module must have exactly one
claims_dir/<module>/manifest.yaml — YAML only, not a claim. It is the
durable "why / start here": a short summary (at most 280 characters), a
provides export list of this module's contract-facet claim ids, and a
depends_on list of other modules' contract ids, each of which must be in its
provider's provides. The file is capped at 4096 bytes. check and
claim lock refuse a missing, oversize or invalid file (module-manifest),
and a module with no valid manifest has no lockable claims. claim new writes
an empty stub that fails until an agent drafts it from
dossierx manifest show <module> --isolation; do not paste claim bodies.
The manifest summary is where the module start-here lives.
See FORMAT.md.
Claims. A claim is one atomic, YAML-authored fact about a system: a field table, a sequence of steps, a paragraph of prose, a piece of hand-authored mockup HTML. One claim per file, under the project's claims_dir.
id: widget.contract.overview # module.facet.slug
facet: contract # the tab it renders under
module: widget
status: draft # draft | locked
summary: A widget is the smallest documented unit.
layout: card
body: |
A widget is the smallest unit this project documents [1].
rests_on:
- widget.internals.storage # this claim is true only while that one is
sources: # optional: the evidence, cited from body as [n]
- ref: 1
kind: external # external | internal
title: Widget specification
url: https://example.com/widget-spec
accessed_on: 2026-08-15 # what makes an external citation falsifiableEvery claim has an id (module.facet.slug, or project.<slug> for a project claim), a status, a required one-line plain-text summary, a layout (card, table, list, steps, tree, banner, mockup) and a required rests_on — a list of claim ids, or {none: true, reason} for a claim that rests on nothing. Module claims also carry a facet and a module; project claims carry neither. Claims name other claims they rests_on, forming a graph the engine walks and validates; the constitution is never a target. The full schema is in FORMAT.md. A claim file carrying a retired field (governed_by, build_role, mirrors, migrated_from, tracks) fails to load; the dossierx-upgrading skill folds it.
Sources are optional, added in v0.6.0 and a no-op for a project that does not use them. sources carries a claim's evidence inside the claim — cited from the body with [n] markers, anchored by an access date when the source is a page that can change under you and by a content hash when it is a file the engine can read, and signed by the lock ledger so a citation cannot be rewritten after approval.
check is the pipeline. One command runs lint → catalog/readiness projection → conformance evaluation → bounded viewer projection and generated writes → code-link scan → the ledger gate → the code-link gate, and stops at the first failure. The code-link gate applies once source_dirs is set: every locked module claim must carry a link in source (project claims, and a claim declaring embodiment: {mode: none, reason}, are exempt), and a claim with steps: must be tagged on every step, or check exits 1 (unlinked_claims, stopped_at: links) after the viewer was regenerated. --validate is the read-only form for the authoring loop. A project with no embodiment declarations retains the established lint and ledger checks and does not build a catalog or viewer in memory. Once any claim declares embodiment, validation also evaluates its named set/scalar checks and the bounded catalog/readiness and viewer projections in memory so those three views agree, while still writing nothing — no claim files, no lock store, no build/catalog/catalog.json, no conformance status and no viewer. A compare declaration is implementation-ready only when all of its checks match. With optional conformance.blocking: true, any owed, mismatch, or uncheckable check fails check; this is a checker gate only and does not approve, lock, unlock, or change dependency readiness. Plain check still writes the inspectable conformance outputs before returning that failure, while --validate and --staged remain write-free. It does not reconcile review_pending or scan source for code links. Run plain check before trusting what is on disk.
Proof or stop. Only admitted evidence counts: a dossierx check that exited 0, a lint or ledger finding, a conformance result, the code-link coverage report in data.code_links, a human's Resolve click, or a human's own words in --reason. An agent's statement in chat that "the code matches the claim" or "it is synced" is not a certificate and closes no loop — it is the thing this tool exists to replace. When the evidence is missing, the agent stops and says what is missing rather than filling the gap with an opinion. A model drafts claims, tags and code; it is never the judge.
The lock lifecycle. A draft claim is freely editable. dossierx claim lock <id> promotes it to locked — refused while the project's constitution is not locked (CONSTITUTION_NOT_LOCKED: missing, status: draft, or edited since its lock), if lint has any error, or if the claim still carries an unresolved comment thread. A locked claim never silently changes: it is flagged review_pending on any of three independent triggers — a dependency it rests_on drifted, a dossierx claim flag recorded that its stated behavior no longer matches reality, or an open comment thread was added — rather than being auto-updated. review_pending is set automatically and never cleared automatically; it clears only once every trigger is gone, via one of three matching clearers: a confirmed dossierx claim reaudit <id> --confirm --reason "..." (drift/flag; it writes to the lock ledger, so it is refused CONSTITUTION_NOT_LOCKED at the same roof gate as claim lock), dossierx claim unlock, or the human resolving the last open thread in the viewer.
Local approval and dependency readiness. Every project uses lock policy v1. A lock store written under the older policy loads as v1 and records the carry-over on its next write; no approval, baseline or review cause changes. Under v1, claim lock evaluates one requested set whether it contains one claim or many. --dry-run returns every member's local verdict, dependency conditions and a request-bound snapshot; the matching --proposal is mandatory on the write, and an omitted, invalid, stale, or wrong-set token refuses before approval storage changes. A locally approved claim may depend on a readable draft, but it reports dependency_unapproved and is not dependency-ready. A missing, retired, unreadable, or cyclic required dependency refuses approval before any claim or approval storage changes. Local approval, dependency readiness and integrated evidence are different statements. The lock store retains the reviewed dependency content and comparable hash for each new local approval; a hash detects a difference and never proves semantic compatibility.
reaudit is the drift tool, not the general edit tool. It refuses a claim that is not already review_pending, it rewrites only body, and it refuses a claim whose only trigger is an open thread (there is no diff to confirm — resolve the thread instead). To change anything else about a locked claim, the path is unlock → fix → lock.
| Field | Type | Required | Description |
|---|---|---|---|
schema_version |
int | yes | Must equal the version this DossierX build understands (currently 1). |
facets |
[]string | yes | Engine-fixed: must be exactly [contract, internals]. No other facet names are legal. Other modules may cite only contract. |
modules |
[]string | yes | The non-empty, deduplicated list of module names this project documents. |
claims_dir |
string | yes | Directory of claim YAML files, resolved relative to project.config.yaml's own directory (never the process's current working directory). |
build_dir |
string | no | Where every generated file goes — the three ledger stores, code-link records, the catalog and the viewer (default build, resolved relative to the config). It may not be the config's own directory, and may not sit inside claims_dir or contain it. The ledger stores under it are committed; see Where DossierX writes. |
conformance.observations |
string | no | One literal, project-owned normalized observation JSON file, resolved relative to the config and required to stay outside build_dir. DossierX reads it only when a claim declares embodiment; it never runs an adapter. |
conformance.blocking |
bool | no | Defaults to false. When true, any owed, mismatch, or uncheckable named check makes check fail after write-mode outputs are generated; approval and locking remain unchanged. |
title |
string | no | The viewer's display name — used in <title>, the header, and the sidebar heading. Defaults to a generic fallback when unset. |
eyebrow |
string | no | A one-line subtitle rendered under the title in the sidebar header. No line is rendered when unset. |
constitution |
string | no | Path to the project-root constitution.yaml roof (default constitution.yaml, outside claims_dir). Not a module and never a rests_on target; check, claim lock and claim reaudit --confirm refuse CONSTITUTION_NOT_LOCKED until dossierx constitution lock has recorded it. |
project_claims_dir |
string | no | The project-claims store (default project-claims, a sibling of claims_dir): project.<slug> claims with scope: project, no module, no facet, no cap and no manifest. They do not count toward a per-module cap. |
source_dirs |
[]string | no | Directories (relative to the config file) scanned for dossierx-claim: <id> and dossierx-step: <id> #<n> <sha256-hex> source comments — the code side of claim-to-code linking. Unset means "do not scan" and no code-link gate; set, plain check refuses on any locked module claim with no link (unlinked_claims); project claims and embodiment: {mode: none} claims are exempt. |
mockup_modules |
[]string | no | The allowlist of modules permitted to author raw_html (on any layout, including layout: mockup). Every entry must also appear in modules. Unset/empty means no module may. |
max_claim_body_chars |
int | no | Project-wide ceiling on body + steps + rows cells, counted as Unicode code points. Omitted, the default is 2000. check fails with body-oversize when a claim is over, and claim lock refuses. raw_html is exempt. Values below 1 are refused at config load. |
max_claim_summary_chars |
int | no | Project-wide ceiling on summary, counted as Unicode code points. Omitted, the default is 200. check fails with summary-oversize when over. Values below 1 are refused at config load. |
max_claims_per_module |
int | no | Project-wide ceiling on how many claims one module may hold. Omitted, the default is 10. check fails with module-claim-cap when a module is over, and claim lock refuses any claim in that module. Raise this number to override; there is no per-module override. Values below 1 are refused at config load. |
briefs_dir |
string | no | The briefs tree (default briefs, beside project.config.yaml): briefs/<folder>/<slug>.md markdown documents plus the images they reference, one folder level, every name in [a-z0-9-]. Absent is no briefs, and a project with none sees no change. It may not be the config's own directory or overlap claims_dir, project_claims_dir or build_dir. See FORMAT.md. |
max_brief_words · max_brief_images · max_brief_image_bytes · max_briefs_per_folder · max_briefs |
int | no | The brief caps: 2000 words per brief, 3 images per brief at 1048576 bytes each, 12 briefs per folder, 60 in total (images count toward neither of the last two). check fails with brief-word-cap, brief-image-cap, brief-folder-cap or brief-total-cap when one is exceeded. Raising one is the human's call. Values below 1 are refused at config load. |
viewer.template_overrides |
string | no | A directory of partial-template overrides, resolved relative to the config file. Missing individual partials fall back to engine defaults; a configured-but-missing directory is a hard error. |
DossierX ships embedded Claude Code skills that teach an agent working in a consuming project how to operate it. dossierx is the router, loaded first and always: the nine nouns, the envelope, the exit codes, the error-code-to-recovery table, and which companion to load next. The companions are dossierx-claims (write, find, and move claims through their lifecycle), dossierx-modules (work one module at a time through manifest show, draft manifest.yaml, and recover from every cap), dossierx-constitution (draft and keep the roof, and author project claims), dossierx-briefs (write, lock and keep the briefs beside the claims, feature briefs included), dossierx-comments (run review threads, and when to comment versus flag), dossierx-code-links (implement from a locked module and ground the code in the claims it implements), and dossierx-upgrading (fold a corpus across a release that changed the format). See skills/ for what each covers.
dossierx skills export [dir] writes them into a project, creating parent directories and overwriting in place, so re-running it is how you pick up a new release's guidance. It also removes a retired bundle — a dossierx-* directory the tree's previous dossierx-skills.lock listed that this release no longer ships, such as dossierx-build-order — and the docs/dossierx-agent-guide.md releases up to v0.7.21 wrote (with docs/ when nothing else is in it), and never touches a directory whose name does not start with dossierx or any other file under docs/; dossierx skills export --check refuses skills_drift with data.retired[] for any such directory or guide still on disk. Step 3 of the paste block above does this — after step 2 has written project.config.yaml, never before, because the export resolves the project root through the config: only a rooted export maintains its section in an AGENTS.md that already exists, while a rootless one exits 0 having written only the bundles, and nothing later in the block exports again. [dir] is optional only inside an existing project — with neither a directory nor a project.config.yaml to root the write in there is nowhere to install to, and the command refuses with write_failed. Step 3 still names .claude/skills explicitly because the harness, not DossierX, decides where skills are read from. Add a project-specific overlay skill alongside them for anything local to your repo — house style, module conventions — that the generic skills cannot know.
The skills are one source written in two forms, because no two agent harnesses read the same file. DossierX writes nothing under docs/; a harness with no skill loader, or a human, reads the exported SKILL.md files directly — they are plain markdown.
| Form | Where | Notes |
|---|---|---|
SKILL.md tree |
the [dir] you name; otherwise every skills tree the repo already has — .claude/skills if .claude/ exists and .agents/skills if .agents/ exists — each with its own dossierx-skills.lock |
verbatim bundles, frontmatter intact |
AGENTS.md section |
an existing AGENTS.md only — never created |
marker-delimited and idempotent; carries the router only, since this text is resident on every turn, and links each companion to its SKILL.md in the tree this export wrote |
Both forms are regenerated by re-running the export, so they are committed artifacts like the ledger: re-export to pick up a new release, and commit the result.
DossierX is a CLI only — there is no public Go package API. Everything here is invoked through the dossierx binary; internal/ is not importable by other modules and its structure is free to change between releases without that counting as a breaking change. The runtime dependency set is deliberately two packages (cobra and yaml.v3) and adding a third is a decision, not a convenience.
See CONTRIBUTING.md for the development workflow, package boundary rules, and how to run tests and lint locally.
Apache License 2.0 — see LICENSE.