ADR-0027: Accept validates before it moves, and every revision pins its body
- Status: accepted
- Date: 2026-08-23
- Deciders: Theurian maintainers
- Requirements: FR-K4, FR-K5, INV-1, INV-3, SEC-7, SEC-11, SEC-13, T-5, T-15
- Decision recorded in #316, adopted 2026-08-22: one pre-1.0 contract break bundling #210, #307 and #198
- Amends the division of labour recorded in ADR-0013 §4; see the cross-reference amendment there
- Rests on ADR-0005 (rule 2, an applied
migration is frozen; rule 8, applying all migrations to an empty store
reproduces the full canonical state; and the scope of the
apiVersionbump) and ADR-0006 (expectedRevisionis optimistic concurrency, never a merge). Names ADR-0018 for a residue it does not close
Every number in this ADR was measured on 2026-08-23 against main @
68e8a0b, except where a later dated measurement is named inline. Where a
measurement came from running the CLI rather than from reading the tree, the
flow that produced it is named beside it. The cost model's large-corpus figures
and the $TMPDIR residue below were measured on 2026-08-24 by the round-one
security review, and say so where they appear.
Context
Three open defects in Theurian's write path share one shape: a check exists, and it runs somewhere other than where the decision is made.
- #210.
opUpsertRevision.contentSha256is schema-optional. A migration that declares no pin is loaded with the body's current hash adopted as though it had been pinned, so an out-of-band edit to that body is invisible afterwards.migrate validatewarns; nothing refuses. - #307.
theurian propose acceptmoves a proposal's files into.theurian/migrations/and.theurian/knowledge/and then deletes the proposal directory, without validating what it moved. A self-inconsistent hand-authored proposal therefore lands, failsmigrate validateproject-wide, and is no longer available to re-accept — its sources are gone. Three faces were demonstrated on the issue: two operations naming onecontentFile, a self-pin that mismatches its own body, andcontentFile: ""under a schemaminLengthof 1. - #198. SEC-11 — "scan content for secrets before it becomes an approved revision; block or warn per policy" — is not implemented. T-15 is graded High — no content scanner ships precisely because of that absence, and six tracked documentation and configuration surfaces say so in the present tense.
Theurian has no external users and 0.1.0.dev9 is the live release, so the
cost of breaking a published contract is an edit to this repository's own files
and nothing else. The maintainer decision on #316 was to spend that window
once, on all three, rather than three times.
Decision
1. contentSha256 is required on upsertRevision, tightened in place
contentSha256 joins required in schemas/migrations/migration.schema.json
$defs.opUpsertRevision, which becomes
["op", "itemId", "revisionId", "contentFile", "contentSha256", "metadata"].
apiVersion stays theurian.dev/v1.
Three measurements are the grounds, and each is a fact about the tree rather than a judgement:
Measured 2026-08-23, main @ 68e8a0b |
Result |
|---|---|
| Does the generator ever emit an unpinned revision? | No. ProposalService.draft computes ContentHash.of_bytes(body_bytes) for the body it is about to write and passes it into _migration_document on every path (application/proposal_service.py). There is no branch that omits it. |
| Does the loader verify a declared pin? | Yes, on every load. _parse_operation re-reads the resolved body, hashes the bytes it read, and raises MigrationError when a declared contentSha256 disagrees (infrastructure/filesystem/migration_loader.py). |
| How many real migration documents would have to be edited? | Zero. 28 migration documents are tracked in this repository — 26 in the dogfood corpus under .theurian/migrations/, 2 under examples/sample-project/ — and each carries a contentSha256 on each of its upsertRevision operations. The live dogfood project's working tree holds 82 (the 26 tracked plus 56 machine-local operator notes); all 82 pin. |
The population key for the third row, so it can be attacked rather than
trusted: every tracked *.yaml/*.yml containing the line op:
upsertRevision, compared against its count of contentSha256 lines
(git ls-files '*.yaml' '*.yml', then grep -c for each), plus a filesystem
count of .theurian/migrations/*.yaml in the live dogfood project. Both counts
came out equal to the operation count and nothing was unpinned.
Why the tightening does not bump apiVersion. ADR-0005 scopes the bump
rule narrowly — "Adding an operation is a protocol change and requires a
version bump of apiVersion" — and this adds no operation. The loader matches
the version exactly (document["apiVersion"] != MIGRATION_API_VERSION raises),
so publishing theurian.dev/v2 would make every one of the conforming
documents above unreadable by the new build, in exchange for separating a
population of incompatible documents that does not exist. The general question
of how the closed enums and the operation set evolve compatibly is
#274's, and this decision
does not settle it.
What the requirement closes. A hand-authored migration with no pin
currently passes migrate validate at exit 0 with a warning, and the loader
adopts whatever bytes the body holds at load time. After the change, the
absence is a schema error at validate and therefore in CI, before the
migration is applied and before it is merged. FR-K5's tamper evidence stops
depending on the author having remembered.
Two things this decision does not buy, stated because both were claimed during design and neither is true:
- It does not strengthen the accept-path replacement guard. That guard keys on
the landed body's
(st_dev, st_ino)rather than on whether a pin was declared, so nothing about what it refuses moves with this decision.tests/integration/test_proposal_service.py::test_accept_refuses_a_byte_identical_replacement_of_a_pinned_bodyand::test_accept_refuses_a_byte_different_redeclare_of_a_pinned_landed_revisionare the tests that pin it. Their unpinned twins are deleted rather than renamed, and could not have survived: each reached its state by stripping the pin from a landed migration, which after this decision no longer loads. The tightening removed the input they were written against, not the property they asserted — which the surviving siblings hold on the same key. - It does not change what an absent
expectedRevisionmeans. An absentexpectedRevisionpermits a first revision, or an exact re-run of the same revision id, and not only the first:MigrationEngine._check_expected_revisionraisesRevisionConflictErrorwhen an item already exists unless the operation'srevisionIdequals the item's current revision, which is the idempotent re-run ADR-0005 rule 5 requires. The schema's own description ofexpectedRevisionsays "or be absent when creating the first revision", which is the shorter and wrong half of that sentence.
expectedRevision stays optional, and that is a decision rather than an
omission. The bundle's original text required it too. It was re-scoped to
#324 because requiring it
buys no enforcement: the field is oneOf a ULID or null, and an explicit
expectedRevision: null parses identically to an absent key, so
required-and-nullable is satisfied by a document that guards nothing. The
semantics are already enforced where they can be — by the engine at apply, and
by ProposalService._check_expected_revision, which refuses to draft an update
to an existing item with no guard. The edit cost would be all 82 applied
migrations, 56 of them outside Git and outside CI.
The second break riding this one: unpinnedRevisions is removed
migrate validate --json publishes unpinnedRevisions, a list of warnings
about revisions that declare no pin. Once the pin is required, that list is
empty for every schema-valid input, and a permanently empty published field is
a claim that the condition is still reachable. The field is removed. This
is a second, separate break in the same CL — a published-field removal, not a
schema tightening — and it takes its own CHANGELOG entry at implementation.
The domain flag UpsertRevision.content_pinned (domain/migration.py) goes
with it. Its only purpose is to distinguish "declared a pin" from "the loader
filled content_sha256 in from the body it read", and after the change the
loader has no second case to fill in. Its __post_init__ check and the
unpinned_revisions() helper in application/migration_body_guards.py become
a guard no real data reaches, which on this project is a guard that survives
its own deletion.
Two surfaces assert, correctly today, that the unpinned state exists, and both are updated in the implementation commit rather than left to rot:
docs/protocol/migrations.md, the section "What an unpinned body does not get" and theunpinnedRevisionssample output beneath it.tools/corpus_drift.py, whose docstring says its conditional pinned-body read "is still declared here, because a declaration that describes the corpus rather than the code stops being true the moment somebody commits an unpinned revision." After this change the code, not the corpus, is what makes every anchor pinned, and the sentence has to say so.
2. propose accept validates, then moves
Before it moves anything, accept proves that the union of the landed
migration set and the incoming proposal survives the same pipeline migrate
apply runs. If it does not, accept refuses and consumes nothing.
That replaces the stance recorded in _parse_migration's docstring —
"Deliberately not a validation pass. accept moves files; whether the
migration is well-formed is migrate validate's question" — which ADR-0013 §4
is the source of. The division was defensible when accept was a mv with
guard rails. It stopped being defensible once the command also deleted its
sources: a check that runs after the input is destroyed cannot be acted on.
The pre-check has four stages, in this order:
- Schema and document limits —
validate_migration_document(migration_loader.py), the same entrydraftalready calls, followed by theapiVersionexact-match check. - Self-consistency of the incoming proposal — the digest verification the
loader performs when it re-reads a referenced body, and
refuse_duplicate_content_files(application/migration_body_guards.py) scoped to the incoming operations together with the landed set. - The whole-set guards
migrate validateruns —refuse_unenforceable_scope(application/migration_engine.py),refuse_duplicate_content_files, andrefuse_alias_item_id_collision(application/migration_alias_guards.py). The population key is the guardsvalidate_commanditself calls; re-enumerate it withgrep -n 'refuse_' packages/theurian-core/src/theurian/cli/commands.pyrather than trusting this list. - A dry replay of landed ∪ incoming against a throwaway target, which is the stage that catches what nothing above can: the invariants the engine enforces only while applying.
Stages 3 and 4 overlap, deliberately, and the stage list should not be read
as a partition. MigrationEngine.apply calls refuse_unenforceable_scope,
refuse_duplicate_content_files and refuse_alias_item_id_collision itself,
after planning and before any write, so the replay re-reaches all three. Stage 3
runs them statically first for message quality — a refusal that names the
offending migration rather than surfacing from inside a replay — and not because
removing it would leak a face past the pre-check. What stage 4 alone covers is
the invariants the engine can only check while it is applying — a revision's
source anchor, a reused revision id, the revision conflict measured below.
The rehearsal starts from an empty database, and that bounds what it can
cover. rehearse_migration_set (cli/migration_pipeline.py) creates a fresh
store and applies the union into it, so verify_no_applied_migration_changed —
the applied-checksum invariant (FR-K5, ADR-0005 rule 2: same id, different
checksum is a fatal error, never an auto-repair) — is structurally unreachable
inside the replay. That check compares each migration against the checksums a
previously active database recorded, and the rehearsal's store has recorded
none: MigrationEngine.plan still calls it, but over an empty recorded map, so
it is a no-op there by construction rather than by luck. The invariant is held
one layer up, by the composition root — the CLI's own gate runs
verify_no_applied_migration_changed against the real active database before it
calls apply (cli/commands.py, the MigrationChecksumMismatchError path). So
the rehearsal covers the apply-time invariants reachable from an empty store — a
revision's source anchor, a reused revision id, the revision conflict,
unenforceable scope, duplicate content files — while the applied-migration tamper
check is not the shared pipeline's to keep. A second write root — Milestone 7's
MCP write path — inherits this boundary: wiring the rehearsal buys it every
invariant in that list and not the checksum gate, which it must run itself
against the real store. This does not weaken accept's closure; it makes the
closure honest about where its boundary is.
The dry replay is not new machinery; it is a property the format already
promises. ADR-0005 rule 8 — "Applying all migrations to an empty store
reproduces the full canonical state" — is what makes replaying the set against
a throwaway target both well-defined and side-effect-free. A replay that
disagreed with the real apply would be a violation of rule 8 before it was a
bug in accept.
Hard condition on the implementation: the replay invokes the same engine path
migrate apply invokes, differing only in the write target. Not a
replay-shaped subset, not a re-implementation that checks the same invariants.
A second implementation makes the closure argument below false on the day it is
written, whether or not the two agree that day.
The closure argument, which is the point of the design and not a description
of it: accept refuses any proposal whose acceptance would leave landed ∪
incoming unable to complete the pipeline migrate apply runs, and the
pre-check executes that pipeline's own code rather than a second copy of it.
A divergence between what accept answers and what apply would answer is
therefore not a bug to be found later; it is unreachable for as long as the
sharing holds. This project has been burned twice by the other shape — two
detectors deriving one fact independently and drifting — so the sharing is the
requirement, not the reuse.
The apply layer has at least three faces, and the replay closes the ones
nobody has written yet. The revision conflict below is the measured one. The
other two are named by the product's own output: _ACCEPT_STEPS in
cli/propose_commands.py tells every caller that "source anchors and
revision-id reuse are checked by theurian migrate apply, after the pull
request has merged." A per-face guard strategy would need one new guard for
each apply-time invariant that exists today and one for each added later; the
replay covers the set by construction, including invariants that do not exist
when this ADR is written.
Why stage 4 is not optional, measured 2026-08-23 on a scratch project
against a development build of main @ 68e8a0b:
- The honest sequential flow does not cross-record. Draft A, accept A,
draft B with
--expected-revision, accept B, onemigrate apply: each revision's landed body and itscontent_sha256row match that revision's own source bytes, and the item pointer ends on B. #210's clause about two proposals cross-recording bodies is measured on this flow and does not reproduce. - The racing flow produces a set that validates and can never be applied.
Draft A and draft B both before either acceptance — so both claim the
item's first revision — then accept both.
migrate validateexits 0 withvalid: true.migrate applythen refuses withRevisionConflictErrorat exit 4 ("migration expected<none>, store holds<revA>"), atomically: zero rows land and no partial state is written. Nothing cross-records, and the set is nonetheless validate-green and apply-red permanently, with both proposals already consumed.
Stages 1–3 do not catch that. migrate validate is schema conformance plus the
statically decidable set guards, by recorded design — its own docstring says it
gives "no guarantee that validate cannot pass a document apply will reject"
(#36), and the propose CLI
tells the caller the same thing: "Validation is schema conformance and nothing
more." The dry replay is what makes the refusal cover the racing face, and it
is what lets one closure argument cover all four faces instead of three plus a
surprise.
The racing face is graded HIGH: shipped behaviour is wrong, and nothing is
disclosed. accept exits 0 and yields a set migrate validate calls green
and migrate apply refuses permanently, with both proposals already consumed.
It is not a false published claim — _ACCEPT_STEPS honestly says validate
"does not prove the migration will apply" — and it discloses nothing the caller
may not read, which is what keeps it off CRITICAL. What makes it HIGH rather
than MEDIUM is the recovery: the only way out is deleting a landed migration
from .theurian/migrations/, and
plugins/claude-code/commands/propose.md
forbids exactly that to the documented actor — "Do not write into
.theurian/migrations/ or .theurian/knowledge/ directly". The documented
agent can reach the state and cannot leave it.
The recovery property this buys. Today a self-inconsistent proposal lands, breaks the project's validation, and is gone; re-drafting is the only way forward. After this change the proposal survives its own rejection, because nothing was consumed. That is the outcome #307 asked for, stated as a property rather than as a fix.
Cost, measured rather than estimated — and its shape is O(corpus bytes), not
a constant. The replay stages landed ∪ incoming into a throwaway tree before it
applies, which means it copies the corpus — every landed migration, every
referenced body, and a freshly built state database — into $TMPDIR. The cost is
therefore dominated by that tree copy, and grows with the corpus's bytes, not
process startup. On the live dogfood corpus — 82 migrations, 164 operations — a
full migrate apply took 0.55 s wall and migrate validate's load-and-guards
pass took 0.56 s (measured 2026-08-23 on a development machine, against a
scratch copy). That near-equality is a property of this corpus, whose bodies are
small: when the bytes are few the copy is cheap, and the two figures coincide. It
does not generalise. Measured 2026-08-24 by the round-one security review, against
a synthetic 240 MiB corpus: migrate validate took 0.53 s, while propose
accept took 3.76 s on the success path — one rehearsal — and 5.66 s on a
refusal, because _landed_set_alone_fails (application/proposal_service.py)
runs the rehearsal a second time to separate the proposal's fault from a
pre-existing one in the landed set. The gap between 0.53 s and those figures is
the tree copy, and it scales with the corpus. This is bounded work, not
unbounded: accept is a local, human-gated, interactive command, and the
corpus it copies is the operator's own, so no caller can make the system spend
work not bounded by the operator's own corpus size. The correction is to the cost
model — it is O(corpus bytes), not a constant "process startup" — and not to
the conclusion, which stands.
Three residues, named rather than closed:
migrate validate's no-replay division stands by recorded contract (#36), and the measured consequence is worth stating plainly: a validate-green, apply-red-forever set is reachable for a migration placed into.theurian/migrations/by hand, because such a migration never passes throughaccept. Whethervalidateshould gain a replay stage of its own is out of scope here.- No CI job applies the committed corpus, so every apply-time invariant is
unverified against the shipped migrations. That is a different class from
this decision — it is about what CI runs, not about where
acceptchecks — and it is filed as #325. It is cited here and not absorbed. One detail matters for reading it correctly: the corpus guard's uniqueness rule,test_dogfood_corpus_governance.py::test_every_committed_revision_id_is_unique_across_the_corpus, keys onrevisionId, notitemId, so a racing pair — two distinct revision ids for one item, each claiming the item's first revision — passes it. - Two
acceptinvocations racing at the process level stay deferred, and this decision widens the window. ADR-0018 makes single-writer a contract in the application layer, enforced in Milestone 1 by an OS advisory file lock on a separate lock file,.theurian/runtime/write.lock, held for the duration of a write transaction and guarding the state databases under.theurian/state/— and the accept path's file moves are not under that lock. So each of two concurrent invocations can pre-check against a landed set the other is about to change, and the replay lengthens the interval between examining and moving by the replay's own duration. Saying so is part of the closure argument, not an aside: a reviewer who finds this unstated is right to call the closure incomplete.
3. SEC-11 ships as a real control on the accept path
An in-house content secret scanner runs over the proposal's body files before
accept moves anything. Its policy comes from security.secretScan in
.theurian/config.yaml: block — also the behaviour when the key or the file
is absent — refuses the acceptance with a remedy; warn proceeds and reports;
off skips the scan.
The detector is written here, and takes no new dependency. The approach is
the one this repository already uses against its own plugin tree for SEC-5:
pattern families for known token shapes, plus a Shannon-entropy heuristic over
candidate tokens. That detector lives in
packages/theurian-core/tests/unit/test_secret_detector.py and is a test-only
walker, so the shipped control is a new module rather than a move — but the
technique is one this project has already tuned, self-tested, and run against
real content.
The stance on completeness is the one SECURITY.md already publishes: "Run a repository secret scanner — Theurian is not one and is not a replacement for one." A best-effort in-house detector is consistent with that sentence. Taking a scanning dependency to raise the detection rate was rejected, because it enlarges the dependency footprint (ADR-0014 pins every dependency exactly, and each one is a supply-chain surface) to improve a control the product deliberately disclaims completeness on.
The scan is body-scoped, by decision. _scan_bodies_for_secrets reads the
incoming body files and nothing else, so a secret placed in the revision's own
metadata — its --title, --description or --label, or one of its source
anchors — is not scanned. This is a stated boundary rather than an oversight, and
it is a real disclosure channel, because two of those metadata channels are
published verbatim on every knowledge.search and knowledge.get result
(mcp/results.py, verified 2026-08-24 against the round-two security review): the
title, and the source anchors — provider, sourceUri, repository, commitSha,
filePath — set by theurian propose's --source-provider, --source-uri,
--source-commit and --source-path, or by hand in the authored migration. A
URL, repository or file path is exactly where a credential in a token-bearing URL
hides, so the anchors are at least as sharp a published channel as the title. The
title, lowercased, also becomes the migration filename's slug, so a slug-surviving
credential such as an AWS access-key id lands in both. The description and labels
land unscanned in the migration metadata too, but they are not in the result
payload — unscanned-but-committed rather than unscanned-and-published. It is
deferred to #336 rather than
folded in here for three reasons stated so a reviewer can weigh them: the
metadata is bounded and human-gated the same way the body is — a human reviews
the migration diff, which carries the metadata as plainly as the body — so it is
not an undisclosed channel; shipping the body scan first is what #316's window
bought, and widening the scanner is additive rather than another contract break;
and the metadata fields are short, structured inputs where a false positive under
block is more disruptive than in a body, so the extension wants its own tuning.
The graded finding is HIGH — shipped behaviour lets a secret reach a published
field the control does not cover — and this paragraph is its recorded, CRITICAL-
free design decision, not a neutral gap note. What grade this leaves T-15 at is
decided in the threat model, not here; it stays High, and the metadata channel is
one of its recorded residuals.
Amended in Milestone 7, by the metadata-scan CL (#336). The text above is the decision as accepted; the boundary it records no longer holds, and neither does this decision's opening sentence — "runs over the proposal's body files".
As accepted, this decision scoped the scan to the proposal's body files and named the metadata channel a stated boundary, deferred for three reasons: the metadata is human-gated the same way the body is, shipping the body scan first is what #316's window bought, and short structured fields wanted their own false-positive tuning.
theurian propose acceptnow scans the migration document's author-written strings as well as the bodies, so "the scan is body-scoped, by decision" is false and the symbol it names,_scan_bodies_for_secrets, no longer exists —_scan_for_secrets,_document_findingsand_authored_stringscarry the reach and its reasoning.What implementing the deferral revealed is that two of its three reasons were weaker than they read. "Human-gated the same way the body is" is true of where a value lands and false of how it is reviewed: a body arrives in a pull request as a file a reviewer opens, while a title arrives as one line of YAML beside a ULID — and the title and the source anchors are published verbatim on every
knowledge.searchandknowledge.getresult, so a credential in one is disclosed to an agent that never opens the body. The tuning reason was measurable rather than arguable, and the measurement went the other way: over the migration corpus this repository tracks — the 26 documents under.theurian/migrations/and the 2 underexamples/sample-project/— the scan reports nothing: 510 author-written strings, zero findings (measured against67727eb). The live dogfood machine's fuller corpus of 82 (those 26 plus 56 machine-local operator notes) scans clean too, but is not reproducible from the repository. What those strings needed was the detector's ULID subtraction, which was already load-bearing for bodies.The new answer is better for a reason the old one could not state: the population is bounded. Each of the schema's fourteen operation branches and each leaf object it defines (anchors, metadata) declares
additionalProperties: false— the$defs/operationoneOfwrapper does not itself, but every branch it selects does — so the string fields those objects name are exactly what a documentacceptcould apply may carry. The scan reads that set and subtracts each derived field only where a mechanism already bars a reported secret: the ULID- and^[0-9a-f]{64}$-shaped identifiers (id,revisionId,expectedRevision,dependsOn,contentSha256), which the detector's class gate cannot fire on; the fixed vocabularies (op,kind,status,trustLevel,sensitivityand the other enums); andcontentFile, a path whose secret-in-filename face is the artifact-level one. The date fieldscreatedAt,validFromandvalidToare not in that subtraction — they are scanned, because a committed secret in one was reproduced verbatim by the rehearsal's date parse and scanning pre-empts it with a redacted refusal.acceptmoves two artifacts into the canonical tree and only two — the bodies and this document — so between them the gate sees the author-written bytes the acceptance makes canonical, but not the artifact level: a YAML comment, and the migration and body filenames, are unscanned and tracked as their own face (#349). The filename in particular does not follow from the title's scan — the slug is not re-derived from the title at accept (_require_filename_matches_idchecks only the ULID prefix, and a hand-authored slug is free-form), so it is #349's face and not the title's.What does not change is the grade or the disclaimer. T-15 stays High: the count that decides it is over the three points content enters the canonical store, and this widens the one already covered rather than covering a second. The detector is still best effort, there is still no per-finding suppression, and a proposal's
evidence.jsonis still unscanned —acceptnever moves it into the canonical tree, so it is tracked with the draft-time advisory (#330).Further amended in Milestone 7, by the artifact-scan CL (#349). The paragraphs above record the metadata amendment's own boundary — "the artifact level: a YAML comment, and the migration and body filenames, are unscanned" — and that boundary no longer holds.
The metadata amendment scanned the document's author-written string values and named what it did not reach: a YAML comment, the migration and body filenames, and the parsed
contentFile. #349 reads all of them. The scan now covers everything the acceptance lands, not only what it parses — the migration file's raw bytes (so a comment and every field as written), the migration filename, each landed body path, andcontentFile's parsed value, which moved into_authored_stringsbecause it is the one channel that catches a credential both..-collapsed and YAML-escaped, where the byte and path channels each miss. What implementing it revealed is that a finding's location was the last place a credential could still be republished: the body-content channel located itself by the very landed path that was the secret, walking around the four-character redaction bound the detector holds on the match. Every location is now a fixed module literal plus an index, so no refusal and noaccept --jsonresult reproduces more than that prefix — which is the better answer because the bound now holds on every channel, not just the field walk. Two residuals stay open, named as their own faces rather than folded in: the general name hygiene of refusal messages elsewhere on the accept path, which still echo an author's filename, id orcontentFileverbatim (#360), and a proposal'sevidence.json, whichacceptstill leaves in the directory it tells the author to commit (#361). The filename channel is narrow by construction — a lower-case-kebab slug can spell onlysk-andxox, two of the detector's eight families — but the less-restricted landed-path channel is not so limited: a path component admits upper-case letters, digits and_, so every family the slug excludes can be spelled and caught in one. T-15 stays High: this widens the one gate of three that was already covered.Further amended in Milestone 7, by the accept-disclosure CL (#360, #361, #339; PR #536). Both amendments above close by naming two residuals, and this CL discharges both of them. The sentences that no longer hold are the metadata amendment's "a proposal's
evidence.jsonis still unscanned —acceptnever moves it into the canonical tree" and the artifact amendment's pair, "refusal messages elsewhere on the accept path… still echo an author's filename, id orcontentFileverbatim" and "evidence.json, whichacceptstill leaves in the directory it tells the author to commit".What implementing them revealed about the
evidence.jsonreasoning: the premise was true and the conclusion did not follow.acceptdoes move neither the evidence record nor the proposal directory's name — that much was right — but "so neither is an artifact this scan can be about" reads lands it for puts it into the pull request, and this command separates the two. Three of its own facts do so:_remove_proposal_sourcesdeletes the migration and every body and leaves the evidence behind;accept's first next step tells the author to open a pull request with the proposal directory in it, because the merge is the approval (ADR-0013 point 7); and.theurian/proposals/is not git-ignored. Measured on63e3851, a proposal whoseevidence.reasoningcarried a detectable token was accepted under the shippedblockdefault withfindings=0, and the token-bearing file was still on disk in the directory the command had just told the author to commit. The scan's population is therefore restated: what an acceptance puts into the pull request, not what it lands. Six inputs, the evidence record being the one that is not an artifact of the move.Scanned rather than removed or ignored, and the alternatives are not open.
_read_evidence_recordreads the file to answer whether a proposal has already been accepted, so deleting it destroys the diagnosis whose wrong answer tells an author to mint a second migration for a change already in history; and git-ignoring the proposal directory contradicts ADR-0013 point 7, under which the directory is the committed input a human reviews. It is scanned whole-text rather than by a field walk, unlike the migration document: what lands in a revision is a parsed value the loader reads, but what travels here is the file byte for byte, so the bytes are the artifact — andreasoningis free text under no schema constraint, so an enumeration would gain an unscanned channel the next time the record gained a field. The stated residual is the mirror of that choice: a credential spelled with JSON\uNNNNescapes sits in the parsed value and not in the bytes. It is unreachable through the recorddraftwrites, sincejson.dumpsescapes non-ASCII and never ASCII.What implementing #360 revealed: the refusal messages were not merely untidy — several of them beat the scan to the terminal. The artifact amendment graded this "general name hygiene, pre-existing, and disclosing no content the caller may not already read". The first half stands; the ordering does not. Measured on
63e3851under the shippedblockdefault, a credential placed in a migration filename, in acontentFileor in a migration's inneridwas echoed at full length into the terminal and intoaccept --json— the same string the scan would have redacted to four characters, printed whole by a refusal that fires before the scan runs. It is closed as a class rather than site by site: one gate,_bounded, scans an untrusted string whole and then cuts it to what may be printed. That order was inverted at first, on the argument that a gate must key on exactly what it prints — right about GHSA-3f65's lesson and wrong about its direction. Cutting first makes the boundary itself a leak: a credential straddling it leaves a sub-floor fragment in the head, the head scans clean, and 31 of 43 characters print. Scanning the whole alone inverts that into a worse leak: four of the detector's six specific families are{n,255}followed by a negative lookahead over their own character class, so a candidate run past that cap matches nothing at all — and-and_are candidate characters, so an ordinary descriptive slug after a credential is enough. The cut then creates the boundary the lookahead wanted, the whole scans clean, and the 200-character head prints the credential whole, 43 of 43, in the message and again in the remedy. The cliff is one character wide: a run of 255 is caught, 256 is not.So the gate scans both, and either one reporting withholds the string. The superset argument that defended a single scan is false and was the incomplete closure the second round found: set containment says nothing about how a detector answers, and this one is not monotone under truncation in either direction. What holds is the property stated directly — every string that prints has itself been scanned, as printed — with the whole scanned besides, so a credential the cut would sever is still caught. A string the detector reports is replaced by a literal of the module's own and never partially echoed, because the detector publishes no match length and a "clean" remainder around a redacted span is a partial copy besides. Two channels the issue's own table did not list are in the class and close with it: PyYAML's parse error, which quotes the offending source line before anything is scanned, and
jsonschema's message, which quotes the offending instance in full.Why this is the better answer: the population is proved rather than enumerated. A hand-written list of refusal sites is exactly the artifact that drifts — the issue's table listed seven, and reading for them found seven, which is the failure mode.
test_proposal_refusal_names.pyreflects overproposal_service.py's own syntax tree instead, so a refusal added next year with a raw{path.name}in it goes RED without any reviewer having read the file. An addition is either routed through a gate or recorded in the allowlist with the reason it needs none, and writing that reason down is the review, because it is the sentence that is false when the value is in fact the author's.Four residuals, recorded rather than folded in. The gate judges one string at a time, so a credential split across two author names is recovered whole from one refusal: each half falls below the detector's 32-character floor, both print, and concatenating them reconstructs the value (measured 2026-09-04, a 43-character token cut into two migration filenames). It takes an adversary who cuts the value on purpose, and the alternative — scanning the assembled message rather than each string — would redact every refusal naming two clean paths whose concatenation happens to clear the floor. The gate's reach is also the detector's reach: it withholds a string the detector reports and does not promise no credential is printed. Measured 2026-09-04 through the real CLI, PyYAML's
Mark.get_snippetcut the front off a long line, so a 43-charactersk-token reached the refusal with its prefix already gone — 32 lower-case hex characters that no family matches — and was printed; a third party's truncation rather than something this module chose to quote, and the residual every caller of this best-effort detector carries. The population is also one module's:infrastructure/filesystem/migration_loader.pyprefixes a landed migration's filename onto everyMigrationError, and the CLI loads the migration set during context resolution, so that message arrives beforeacceptruns at all — a different producer, reached throughcli/context.py'sresolve_contextand so by every command that resolves a project context, recorded and reported as #537 rather than closed from here. Andaccept --json'smigrationFileandbodyFilessuccess fields still name landed paths at full length by decision recorded at the site: a success payload whose job is to say what was written reports nothing if it is redacted. A secret-shaped landed path reaches that field three ways -- underwarn, underoff, and underblockwhen the detector misses it -- and only the first of the three publishes the same string redacted beside it insecretFindings.What does not change is the grade or the disclaimer. T-15 stays High: the count that decides it is over the three points content enters the canonical store, and this widens the one already covered. The detector is still best effort, and there is still no per-finding suppression. Draft-time advisory scanning (#330) is still owed, and is now the whole of what that issue carries.
This is the first code in src/ that reads .theurian/config.yaml. Nothing
reads it today — infrastructure/github/__init__.py mentions the filename in a
docstring and that is the whole of it, which is the state
#129 recorded.
packages/theurian-core/tests/unit/test_config_key_call_sites.py exists to go
RED on exactly this diff; its module docstring names the intended failure mode
as "a Milestone 7 diff" and lists what must happen in the same change. A
reviewer seeing that test fail is seeing it work, and the implementation
commit updates it rather than silencing it.
Amended 2026-09-07, by ADR-0030 slice 1. The paragraph above records this ADR's date, with decision 3's reader still in flight. It is a record now, not a description of the tree.
Decision 3 shipped that reader:
security/project_config.pyhas opened.theurian/config.yamlforsecurity.secretScanever since. ADR-0030 decision 2 added the second key — the same module readsproviders.review.repositoriesout of the same file, andsecurity/review_allowlist.pyenforces it: a repository the list does not name produces no spawn at all. The file is therefore read for two keys, andtest_config_key_call_sites.py's reader scan records those sites rather than their absence. The paragraph above stays as the state this decision was taken against, which is what an amendment keeps and a rewrite loses.Amended again 2026-09-07, by ADR-0030 slice 2, and the count is why this note exists rather than an edit above it. A third key joined the same day:
providers.review.redactParticipantNames, R-12's ingestion-time redaction switch (decision 3), read by the same module and applied by the review landing gate. Slice 1's "two keys" therefore stands as slice 1's measurement and not as today's. The live count is not a sentence in this ADR at all — it istest_config_key_call_sites.py'sWATCHED_SPELLINGSreader scan andtools/audit/config_object_claims.py'sKEYS_WITH_A_READER, both of which go RED when a fourth arrives.
Consequences
Positive
- The write path's checks move to where the decision is made. A pin that is required is checked in CI before merge, not warned about after; a proposal that cannot become part of a working set is refused before its sources are destroyed.
- One closure argument covers #307's three demonstrated faces and the racing face that measurement turned up, because the argument is about the pipeline rather than about the faces.
acceptandmigrate applycannot disagree about whether a set is usable, by construction rather than by two tests that happen to agree today.- T-15 gains its first automated control at the point SEC-11 names, and six documents stop describing an absence.
Negative
- Three published contracts break at once: a schema field becomes required, a published output field disappears, and a command that used to move files unconditionally can now refuse. Each is named as breaking in the CHANGELOG with its old and new shape. There are no external users to absorb it, which is why the window was spent now.
accept's failure surface gets wider. It now loads and replays the project's whole migration set, so a project fault unrelated to the proposal — a corrupt landed migration, an unreadable body — can make an acceptance fail. Every such fault must reach the caller as aProposalErrorwith a remedy naming what to fix, under the{error, remedy}contract #227 established. The time cost is O(corpus bytes) — small on a small corpus (0.55 s over the 82-migration dogfood set) but 3.76 s over a synthetic 240 MiB one (measured 2026-08-24; see decision 2's cost note) — because the rehearsal copies the corpus before it replays; the widened examine-to-move window is the part that matters, and it is recorded as decision 2's third residue.- The secret scanner will produce false positives, and
blockis the default. A high-entropy string in a legitimate document blocks an acceptance until the author setswarnoroff, and there is no per-finding suppression. - A best-effort detector shipping as "the SEC-11 control" invites the reading that content is now screened. The disclaimer has to survive into every surface that flips below, or this decision trades an honest absence for a dishonest presence.
Neutral
- The flip set. Six tracked prose and configuration surfaces currently
assert, truthfully, that SEC-11's scanner does not exist, and each becomes
false the moment the scanner reads the key:
SECURITY.md,docs/security/threat-model.md(T-15's entry and the summary table row),docs/architecture/requirements-analysis.md(the threat table row),docs/roadmap.md,schemas/config/project-config.schema.json(thesecurity.secretScandescription, "Not in force, and reserved"), andexamples/sample-project/.theurian/config.yaml(the comment abovesecretScan: block). Four test files pin those claims and go RED with them:test_config_key_call_sites.py,test_examples.py,test_schemas.py, andtest_dogfood_corpus_governance.py. The population key isgit grep -l -i "no content scanner\|secret scanner\|secretScan\|SEC-11"over tracked*.md,*.jsonand*.yaml, excludingdocs/work-logs/and the CHANGELOG, both of which are historical record and must not be rewritten. Re-run it at implementation rather than trusting this list. - Two further surfaces flip with the
unpinnedRevisionsremoval:docs/protocol/migrations.mdandtools/corpus_drift.py, both named in decision 1. - One user-facing string flips with decision 2, and it is the one a caller
reads first.
_DRAFT_STEPSincli/propose_commands.pytells the author that "the invariantstheurian migrate applyenforces — a revision's source anchor, a reused revision id — are checked after the pull request has merged, not before it." After this changeacceptchecks them before the pull request exists, so the sentence becomes false at the moment the pre-check ships.tests/integration/test_propose_cli.pyreads_DRAFT_STEPSand pins its length and first element, so a step count that changes goes RED; a step whose text goes stale does not, which is why it is named here. - One in-source carrier of the old division flips with decision 2, and it
is the docstring that states the old division most precisely.
ProposalService._refuse_if_a_replacement_breaks_an_existing_pin(application/proposal_service.py) records that a self-contained breakage in one proposal — "two operations naming onecontentFile, a self-inconsistent pin, an emptycontentFile" — "lands here and is caught bymigrate validatein CI, which is the check by design (ADR-0013 §4)". The scope of that sentence is correct as a description of what that method refuses, and misleading the momentacceptchecks those three faces itself. It is tightened in the same CL. - The threat model's own T-15 grade is not settled here. Whether the residual
falls from High is a threat-model decision made against the shipped detector,
and it belongs to the CL that ships it. Taken 2026-08-24, in the threat
model rather than here: T-15 stays High, because the shipped control covers
one of the three points a body can enter and the other two are live and
unscanned. Re-graded on 2026-09-03 when
#329 shipped the index-time
control, and it comes back High: the build detects a landed secret in
every text channel of the approved, in-ceiling corpus this deployment serves
by default, but it runs on the far side of the disclosure boundary —
migrate apply, notindex build, is where the content becomes readable — so the count of gates is unmoved. T-15's entry carries the measurement. - Every
acceptcopies the project's bodies through$TMPDIR, and this is an accepted residual. The rehearsal (cli/migration_pipeline.py) stages every landed migration and every referenced body — includingconfidentialandrestrictedbodies — into atempfile.TemporaryDirectory(prefixtheurian-rehearsal-) and rebuilds a fresh state database there, so the cleartext of governed content transits$TMPDIRfor the life of the call. Measured safe on 2026-08-24 by the round-one security review: the directory is created0o700and owned by the process, and Python'sTemporaryDirectorycontext removes it on every exit path — success, refusal, and config-error — leaving notheurian-rehearsal-*residue behind. It is accepted rather than redesigned on those two properties (mode0700plus guaranteed cleanup), with one environmental caveat recorded rather than fixed:$TMPDIRmay sit on a different volume than the project — an encrypted checkout with a plaintext/tmpwrites those bodies to the plaintext volume for the duration — the same shape of accepted environmental residual as ADR-0028'sgit clean -xdfnote, stated so an operator on that setup can weigh it.
What this does not close
Five residues survive this decision, and none is repaired by it. They are recorded here because a reader who takes decision 1 as "bodies are now tamper- evident", or decision 2 as "a broken set can no longer happen", would be wrong in a specific, reachable way each time.
- The integrity anchor lives in disposable derived state.
.theurian/state/is gitignored (ADR-0004), and a rebuild is a sanctioned operation the product's own remedies recommend. An operator who edits a body, recomputes its pin in the migration, and deletes.theurian/state/leaves the product with nothing to detect: every check the loader and the engine perform is satisfied by the rewritten pair. Only Git records what the migration used to say. This is pre-existing and unchanged by this CL — required pins raise the floor for unedited migrations, and do nothing against an edit that touches both halves. - The writer population of
.theurian/migrations/is closed, and that is what makes decision 2 worth having. Measured 2026-08-23 againstmain@68e8a0b:ProposalServiceis the only writer —self._paths.migrationsappears insrc/at four sites inproposal_service.py(one destination computation, two resolutions, and the singlemkdirthat precedes the move), once incli/context.pyas a load, and twice inapplication/setup_steps.pyas anis_dirand aglob. Population key:grep -rn '\.migrations\b' packages/theurian-core/src/theurian/. So after this change the only writer validates, and a file placed there by hand bypasses the pre-check but is caught by the loader on the next load — later thanacceptwould have caught it, and before anything is applied. - Concurrency between two acceptances is unaddressed, and this decision widens the window it opens, as decision 2's third residue states.
- A validate-green, apply-red-forever set stays reachable by hand, because
only
acceptgained the replay. Decision 2's first residue. - No CI job applies the committed corpus (#325) — a different class, cited and not absorbed.
Alternatives considered
| Alternative | Why rejected |
|---|---|
Bump apiVersion to theurian.dev/v2 for the required pin |
There is no incompatible document population for a version boundary to separate: all 28 tracked and all 82 live migration documents already pin (measured 2026-08-23). The loader matches the version exactly, so a v2 const invalidates every one of them for nothing. ADR-0005 scopes the bump rule to adding an operation type, and this adds none. The general enum/operation-set compatibility policy is #274's question, not this ADR's. |
Require expectedRevision as well, as #316's original text proposed |
Re-scoped to #324. The field is oneOf a ULID or null, and an explicit null parses identically to an absent key, so required-and-nullable buys zero enforcement while costing an edit of all 82 applied migrations — 56 of them outside Git and outside CI. The semantics are already enforced by the engine at apply and by ProposalService._check_expected_revision at draft. |
| Repair the unpinned population instead of tightening the schema | There is nothing to repair. The measurement is the argument: zero unpinned documents exist. |
| Fix #210 by editing applied migrations to add missing pins | Editing an applied migration is the fault the product refuses by name: MigrationChecksumMismatchError, ADR-0005 rule 2 — "Same ID with a different checksum is a fatal error, never an auto-repair." The escape the remedy offers is deleting .theurian/state/ and rebuilding, which discards FR-K5's tamper evidence for all 82 migrations at once in order to fix a field none of them is missing. |
Leave unpinnedRevisions in place, permanently empty |
A published field is a claim that its condition is reachable. An always-empty warning list tells a reader that unpinned revisions are a thing that happens and that this project has none right now, which after the tightening is false. Removing it is a break; keeping it is a lie with no expiry. |
Have accept re-implement the checks it needs, rather than calling the loader's |
This is the two-detector shape that has already cost this project a review round: two pieces of code deriving one fact independently agree until they do not, and the disagreement surfaces as "accept said yes and apply said no" with nothing to arbitrate. Calling the loader's own entry points makes the agreement structural. |
Validate at accept but stop short of the dry replay |
Measured: it leaves the racing face open. Two proposals each claiming an item's first revision are individually schema-valid and pass every statically decidable set guard; the set is validate-green and apply-red forever, and both proposals have been consumed. Stopping at stage 3 would close three faces and ship the fourth. |
Keep validation entirely in migrate validate and make accept non-destructive instead |
Leaving the proposal directory behind after a successful acceptance trades one failure for another: the next accept of the same proposal has to decide whether it has already landed, which is the best-effort, untrusted-input diagnosis ADR-0013's amendment describes and #253 hardened. It also does not stop a broken migration from reaching .theurian/migrations/, which is the part CI and every subsequent command then have to cope with. |
Take a secret-scanning dependency (detect-secrets, gitleaks as a library) instead of writing one |
It enlarges the dependency footprint — ADR-0014 pins every dependency exactly, and each is a supply-chain surface — to improve a control SECURITY.md already tells users not to rely on as their only one. The in-house detector's technique is already written, self-tested and tuned in this repository for SEC-5. |
Scan at theurian ingest or at draft instead of at accept |
Neither is the point SEC-11 names. draft produces a proposal a human will read, so a refusal there is advisory. ingest records a manifest of content that is already approved, so a scan there is a different control at a different point — a real one, and out of #316's scope; see the residues below. accept is the approval gate T-15 names, and it is the last place a body can be stopped before it becomes canonical. |
Publish a default for security.secretScan in the schema before the reader exists |
Already rejected once, and pinned: test_schemas.py asserts the key publishes no default, because a default states a policy nothing applies. That constraint lifts in the implementation commit and not before. |
Compliance
Everything owed at design time has landed in Milestone 7, in #316's CL. The
list below names the test that discharges each item, so a reviewer can check it
against the suite rather than against this sentence. Every test path is relative
to packages/theurian-core/.
Landed in Milestone 7 with decision 1 (contentSha256 required):
- The schema requirement, both directions:
tests/unit/test_schemas.py::test_every_upsert_revision_must_pin_the_body_it_namesreads$defs.opUpsertRevision'srequiredand goes RED if the entry is deleted, and::test_a_revision_that_declares_no_pin_is_refused_by_the_published_schemadrives a document that omits the field. The loader half istests/unit/test_migration_loader_required_pin.py::test_a_revision_that_declares_no_body_pin_is_refused_at_load, with::test_the_same_revision_loads_once_it_pins_its_bodyas the control that the refusal is the missing pin and not the fixture. - The "zero edit cost" measurement is now a standing check rather than a
measurement:
tests/unit/test_dogfood_corpus_governance.py::test_every_committed_migration_matches_the_published_migration_schemawalks the tracked corpus against the tightened schema, andtests/unit/test_examples.py::test_the_example_loads_through_the_loader_the_product_itself_runsputs the sample project through the loader the product runs rather than through a schema check alone. unpinnedRevisionsis gone frommigrate validate's output, held bytests/integration/test_cli_commands.py::test_validate_publishes_exactly_the_recorded_key_set— a recorded key set, so a re-added field fails too — and::test_the_human_output_carries_no_pin_warning_eitherfor the second channel. The four tests that read the field were deleted with it, and the key set is what replaced them: a removal held only by deletions is held by nothing.
Landed in Milestone 7 with decision 2 (accept validates first). All three of
307's demonstrated faces are in tests/integration/test_proposal_service.py,
and each asserts the refusal and that the proposal directory is untouched:
::test_two_operations_naming_one_body_are_refused_with_the_proposal_intact,::test_a_pin_that_does_not_match_its_own_body_is_refused_with_the_proposal_intact, and::test_an_empty_content_file_is_refused_with_the_proposal_intact.- The racing face:
tests/integration/test_propose_cli.py::test_a_proposal_racing_another_onto_one_item_is_refused_and_the_rest_applies— two proposals drafted before either acceptance, the second refused, and the set that remains applying cleanly. This is the one that goes RED if the dry replay is removed while stages 1–3 stay. - The hard condition, pinned structurally rather than behaviourally:
tests/integration/test_propose_cli.py::test_the_accept_replay_and_migrate_apply_reach_one_apply_functionand::test_the_accept_pre_check_reaches_the_loaders_own_entry_points_and_every_guard. They walk the call graph in the shapetest_mcp_tools.py::test_no_registered_tool_can_reach_a_canonical_writeuses, so a re-implementation that agrees with the engine today still fails them. - A fault in the landed set is not reported as the proposal's:
tests/integration/test_propose_cli.py::test_a_fault_in_the_landed_set_is_not_reported_as_this_proposals_fault. The delivered contract is narrower than the design text asked for, and deliberately. This item was written as "aProposalErrorwith a remedy naming the landed file"; what shipped isApprovedSetUnusableError— its own subclass, at exit 4 rather than proposal-at-fault exit 1, because exit 1 promises that re-drafting is the recovery and here it mints a duplicate for a fault the proposal does not have (#89) — carrying a remedy that points at.theurian/migrations/and tells the reader to correct what the message names there. Naming the landed file was unimplementable for one of the faces: aRevisionConflictErrornames an item and two revision ids, because the engine does not know which of the two migrations claiming that item is the wrong one. A remedy that is false for a case is worse than one that is general, so the remedy is general and the message does the naming. - The replay writes nothing outside its throwaway target:
tests/integration/test_proposal_service.py::test_a_refused_acceptance_modifies_no_file_anywhere_in_the_projectis the whole-tree diff and content snapshot pointed at a refusedaccept, and::test_the_replay_removes_the_throwaway_tree_it_staged_the_union_inholds the other half — that the target it does write to does not survive the call.
Landed in Milestone 7 with decision 3 (SEC-11):
- Detector self-tests in
tests/unit/test_content_secrets.py, including::test_the_detector_can_fail— a scan returning nothing for a planted secret is this class's failure mode —::test_each_pattern_family_reports_its_own_shapefor the positive fixtures, and::test_the_detector_ignores_a_string_a_knowledge_document_really_containsfor the prose that must not trip it. - One test per policy value, at both layers. Reading:
tests/unit/test_project_config.py::test_a_project_with_no_config_file_blocks,::test_a_config_that_states_no_policy_blocks,::test_each_published_policy_is_read_back, and::test_a_bare_off_is_refused_with_the_quoting_curefor the YAML 1.1 spelling a reader gets wrong by copying the enum. Behaviour:tests/integration/test_proposal_secret_scan.py::test_a_body_carrying_a_secret_is_refused_by_default,::test_a_refused_acceptance_consumes_nothing,::test_warn_lands_the_body_and_reports_what_it_found, and::test_off_skips_the_scan_and_the_body_lands. - The new call site is recorded in
tests/unit/test_config_key_call_sites.py, whose::test_each_secret_scan_prose_surface_states_the_control_and_its_boundnow holds the prose flips as a standing check rather than a one-time edit. The schema default lifted with it, pinned bytests/unit/test_schemas.py::test_the_secret_scan_policy_publishes_the_default_the_loader_applies— which asserts the published default equals whatread_secret_scan_policyapplies, so the two cannot drift apart in either direction.
Landed in Milestone 7 with decision 3's amendment above — metadata-field
scanning (#336), which this
ADR owed as a HIGH finding converted to a recorded design decision. All of these
are in tests/integration/test_proposal_secret_scan.py:
- The refusal, over every field a
propose-drafted document carries:::test_a_secret_in_the_migration_document_is_refused_by_default, parametrized over twelve plants — title, description, author, owner, namespace, label, scope path, and five source-anchor strings — with::test_every_planted_field_reaches_the_migration_document_and_is_detectableas the guard that each plant actually reaches the document and is one the detector reports — without it a green parametrization could be testing nothing. - The recovery property on the new input:
::test_a_refused_metadata_secret_leaves_the_proposal_intactasserts the proposal directory is unchanged and that the document did not reach.theurian/migrations/, which an implementation scanning after the move would fail while satisfying the body-side test. - The other two policies, and the message:
::test_warn_lands_the_proposal_and_names_the_metadata_it_found,::test_off_leaves_the_migration_document_unscanned_too— the escape hatch has to cover the whole control or it is not one — and::test_a_metadata_refusal_does_not_reproduce_the_secret_it_reports.::test_one_listing_bound_covers_the_body_and_the_metadata_togetherholds that the_MAX_NAMES_LISTEDcap applies to the pair rather than once per kind. - The false positive that would make this the first control switched off:
::test_a_title_quoting_a_migration_filename_is_still_accepted_under_block. - The fields no
propose-drafted document can carry, reached by sixteen hand-authored fixtures — one per allowlist field, acrossdeprecateItem,addRelation,addAlias,addEvidence,registerSpecification,changeOwner,createItemandupsertRevision, sinceproposeitself emits only the last two of the schema's fourteen operation types:::test_a_secret_in_a_hand_authored_operation_is_refused_and_the_field_is_named,::test_a_refused_hand_authored_operation_consumes_nothing_and_is_not_quoted_back, with::test_a_hand_authored_operation_is_schema_valid_with_and_without_its_planted_secretand::test_a_hand_authored_operation_carries_no_finding_until_its_field_is_plantedas the guards that a refusal is the scan's and not the schema's. - The population pin that stops the class reopening:
::test_every_drivable_allowlist_entry_has_a_fixture_that_reaches_itreads the allowlists themselves and demands equality in both directions, so a name added to any of them goes RED until something drives it. Four entries are subtracted by name with the reason each is unreachable rather than merely untested:metadata.tenantIdandmetadata.aclGroup, which the engine refuses any value butlocalanddefaultfor (#63), andanchor.commitSha/anchor.blobSha: a schema-valid document holds only^[0-9a-f]{7,64}$there, and the detector's class gate cannot fire on lower-case hex — no credential family it recognises can be spelled in it.
Landed in Milestone 7 with decision 3's artifact-level completion
(#349) — the metadata
amendment above owed the artifact level as its own face, and this discharges it.
The scan now reads everything an acceptance lands, not only what it parses, and
no finding location is built from scanned text. All in
tests/integration/test_proposal_secret_scan.py:
- The migration file's raw bytes, covering a YAML comment and every field as
written:
::test_a_secret_in_a_yaml_comment_of_the_migration_is_refused_under_blockand::test_a_secret_in_a_yaml_comment_is_reported_under_warn, with::test_a_secret_in_a_yaml_comment_is_invisible_to_the_parsed_field_scanas the guard that the comment is a new channel and not already caught by the field walk. - The migration filename:
::test_a_secret_in_the_migration_s_own_filename_is_refused_under_block. Only two credential families can be spelled in a lower-case-kebab slug (sk-andxox), which is why the fixture issk--shaped; the less-restricted landed-path channel is not so limited, since a path component admits upper-case letters, digits and_(measured 2026-08-26: seven families fire by name, and agoogle-api-keyshape is caught ashigh-entropy-token). - Each landed body path, directory components included:
::test_a_secret_in_a_landed_body_leaf_is_refused_under_block,::test_a_secret_in_a_landed_body_s_directory_component_is_refused_under_block, and::test_a_landed_path_secret_the_migration_bytes_do_not_spell_is_still_refusedas the guard that the landed-path and byte channels are complementary rather than one a subset of the other. - The parsed
contentFilevalue, moved into the field walk:::test_a_secret_shaped_content_file_is_refused_by_the_artifact_scanand::test_an_escaped_traversal_content_file_is_refused_under_block— the HIGH the review of #349 reproduced, a credential both..-collapsed and YAML-escaped that only the parsed value catches. - No finding location reproduces the value it reports:
::test_a_name_channel_refusal_never_reproduces_the_name_it_refuses,::test_a_name_channel_finding_locates_by_the_channel_rather_than_by_the_name,::test_a_body_content_finding_does_not_echo_a_credential_shaped_landed_pathand::test_a_body_content_finding_never_spoofs_the_migration_filename_channel— the last two close the review finding that the body-content channel located itself by the very landed path that was the credential. - One listing budget over every channel, not one each:
::test_the_finding_budget_is_shared_across_channels_not_per_channel; and the escape hatch covers the new channels too —::test_off_touches_no_input_before_the_policy_is_readand::test_off_leaves_every_landed_artifact_unscanned. - The false positive that would switch the control off:
::test_an_ordinary_proposal_s_own_artifacts_carry_no_secret_shaped_name— a generated proposal's ULID-shaped names carry no reported secret on any channel.
Still owed, with the issue that will satisfy it:
- ~~Ingest-time and index-time secret scanning~~
(#329) — shipped
2026-09-03, at the index build. T-15's control is approval-time, so this was
a second and distinct control, out of #316's scope.
theurian index buildnow scans every body it indexes, with the source anchors and relation notes served beside it — every text channel of the approved, in-ceiling corpus this deployment serves by default, on every rebuild — so it reaches content that entered throughtheurian ingestor through a hand-placed migration. It reports and never refuses: the build runs aftermigrate applyhas already made the content readable, so refusing to publish would deny ranking without un-disclosing anything.theurian ingeststill runs no scan of its own, and needs none for a reason about storage: it stores no content at all — a manifest and an in-memory read — so at that point nothing is persisted for a scan to have missed. The reason this replaces, "everything its manifest names is read again by the build", was false in both directions: a specification and an orphaned body file are named by a manifest and never enter the canonical store, and a body reaches the store through routes no manifest names. Two parts of the store are outside the build's population and are recorded as residuals in the threat model andSECURITY.md: an unapproved body reachable throughincludeUnapproved, unscanned because reading a withheld row into a published count is the T-17 shape; and a superseded revision, unscanned while it remains in the store, which is why the remedy rotates before it supersedes. - ~~Name hygiene in refusal messages~~
(#360,
#339) — shipped in PR
#536, and it was not only
hygiene: several of those refusals fire before the scan, so the string the
scan would have redacted to four characters was printed whole by the refusal
that beat it there. Every author-controlled string
application/proposal_service.pyinterpolates into a message now passes_bounded, which cuts it to what may be printed and scans exactly that cut text. The population is proved rather than enumerated:tests/integration/test_proposal_refusal_names.py::test_every_interpolation_in_a_message_is_gated_or_recordedreflects over the module's own syntax tree and reddens on a new raw interpolation that is neither routed through a gate nor recorded in the allowlist with its reason, with::test_the_reflection_finds_the_interpolations_it_claims_to_range_overas the positive control that the walk reaches anything at all. The gate itself is held by::test_a_name_the_detector_reports_is_withheld_whole_and_not_in_part,::test_the_scan_reads_exactly_the_string_that_will_be_printedand::test_a_name_past_the_bound_is_cut_and_says_so_without_publishing_its_length; representative members are driven through the realProposalServiceby::test_a_migration_the_parser_refuses_withholds_the_name_and_the_quoted_tokenand::test_a_document_the_schema_rejects_prints_neither_the_value_nor_the_name, because a gate the refusal path does not call passes its own unit test. Three residuals stay open and are named in the amendment above: the detector's own reach,migration_loader.py's out-of-module echo (#537), andaccept --json's full-lengthmigrationFile/bodyFilessuccess fields, which are a recorded decision at the site rather than a gap. - Draft-time scanning as an advisory
(#330). Refusing at
draftwould tell an author sooner, butacceptis the gate, so a draft-time scan is a convenience rather than a control. ~~evidence.jsonbelongs to the same item~~ — theevidence.jsonhalf shipped in PR #536 (#361) and this item is now draft-time alone. The reason it was filed here — "a control over it is a control over something this gate does not land" — was the conclusion the amendment above corrects:acceptdeletes the migration and the bodies, leaves the record behind and then tells the author to commit that directory, so the file reaches Git history without being landed. It is scanned whole-text under the samesecurity.secretScanpolicy, driven bytests/integration/test_proposal_evidence_scan.py:::test_a_secret_in_the_evidence_reasoning_is_refused_under_the_defaultand::test_the_refusal_consumes_nothing_and_the_evidence_is_still_thereforblock,::test_warn_accepts_and_reports_the_evidence_finding_with_the_value_withheldand::test_off_scans_the_evidence_record_no_more_than_anything_elsefor the other two policies,::test_an_unreadable_evidence_record_is_refused_under_blockwith::test_an_unreadable_evidence_record_does_not_stop_a_warn_acceptancefor the one place the two postures differ, and::test_the_refusal_arrives_before_the_author_is_told_to_commit_the_directoryfor the ordering that is the whole point — all resting on::test_the_planted_value_is_one_the_detector_reports, without which each of them would pass against the unfixed build. - Concurrency between two
acceptinvocations (decision 2's third residue), which belongs with the write path's single-writer work (ADR-0018). The accept path's file moves are not under the advisory lock ADR-0018 point 2 describes, and this decision lengthens the examine-to-move window rather than shortening it. - A CI job that applies the committed corpus, which is what would turn the apply-time invariants into something CI checks against the shipped migrations. #325; a different class from this decision, and not closed by it.
- The integrity residue in decision 1 — a body edit plus a pin recompute plus a state wipe is undetectable by the product. Nothing here will close it; Git is the record, and saying so is the control.