Skip to content

feat: write-side trust - identity, permission and review for every agent write (v1.79.0) - #247

Merged
itechmeat merged 90 commits into
mainfrom
feat/write-side-trust
Oct 11, 2026
Merged

itechmeat merged 90 commits into
mainfrom
feat/write-side-trust

Conversation

@itechmeat

@itechmeat itechmeat commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

Open Second Brain 1.79.0 answers the question every operator of an agent-written vault eventually asks: who made this write, what rule allowed it, and how do I stop the next one. Agents authenticate as themselves through named per-agent MCP tokens (hash at rest, shown once); one operator-edited permissions document resolves allow/ask/deny at a single chokepoint; every ask or deny lands in a queryable decision ledger naming the rule that decided; gated writes stage into a review queue recall cannot see until an operator applies them; a cross-owner write gate stops a caller from publishing under someone else's name; open questions get a durable artifact that resurfaces in the morning brief until it becomes a real decision; and the ambient extraction lane answers to operator consent and a time-to-live. The default posture is byte-identity: with no tokens, no document, no gate keys and no flags, every existing write path behaves exactly as before.

flowchart LR
    A["Agent caller<br/>named token or shared key"] --> B["Identity<br/>credential-minted, per request"]
    B --> C{"Permissions document<br/>Brain/_permissions.yaml"}
    C -- "deny" --> X["Named refusal<br/>decision ledger row"]
    C -- "ask" --> E["Staged in Brain/pending/<br/>invisible to recall"]
    C -- "allow" --> D{"Cross-owner claim?<br/>integrity.owner_scope_writes"}
    D -- "foreign owner" --> X
    D -- "own or unnamed" --> G["Published to the vault"]
    E --> H["Operator review door<br/>o2b brain pending apply"]
    H --> G
Loading

What changed

  • Named per-agent MCP tokens, hash at rest: o2b mcp token mint|rotate|revoke|list keeps only the sha-256 hash plus a non-secret prefix on disk, prints the material exactly once, serializes writes under the secrets lock, and appends a no-values custody audit record per lifecycle event.
  • Token authentication with request-scoped identity: a credential matching the token map authenticates the request as that token's agent; identity threads as a parameter through dispatch, never as server state. The shared --api-key stays valid as the operator master credential, a revoked and an unknown token share the same generic 401, mcp_tokens_required (default off) refuses credential-less requests while a map exists, and a presented credential matching nothing is refused outright instead of degrading to anonymous.
  • One bootstrap command: o2b bootstrap --target <harness> runs the adapter's idempotent apply, optionally mints and prints the harness token once, and writes a receipt at .open-second-brain/bootstrap.lock.json; a second identical run is a byte-identical no-op, --rotate re-mints under the same name effective next request, --check verifies drift, and the check path honors the receipt and unreachable-verdict contracts.
  • One permissions document: Brain/_permissions.yaml resolves allow/ask/deny per agent, role and target-scoped entry for the write, ingest and owner_write actions - default_action required, unreadable fails closed with a field-named error, deny over ask over allow at equal specificity, and a present document is the only disposition source so one write has exactly one deciding rule.
  • A queryable decision ledger: every stage/refuse verdict and the owner-write gate's warn rows land as JSONL rows in month/device shards under Brain/logs/decisions/, naming actor, action, target, verdict and the deciding rule; o2b brain permissions show|ledger reads them and o2b brain doctor reports an unreadable document by name.
  • Staged review for every writer lane: beyond the existing signals queue, note creates and ingest summary pages stage under reversible-encoded ids in Brain/pending/, batch create ops stage per operation with pending_id receipts, and the staged documents are excluded from search-index admission so recall cannot surface what no operator has admitted.
  • The owner-write gate: integrity.owner_scope_writes (off|warn|fail, default off, unreadable config fails closed) refuses a caller-named owner that differs from the resolved identity on the preference and note lanes; warn allows with exactly one ledger row, and a document composes most-restrictive-wins with the gate in both directions.
  • Open decisions: o2b brain decision open|list_open|show_open|resolve|discard parks a question with enumerated options at Brain/decisions/open-<slug>.md, dedups on the normalized question, mints a real decision page on resolution, and surfaces in the morning brief at the operator's reach.
  • Consent and a TTL for ambient capture: guardrails.ambient_writeback: false suppresses ambient extraction with one counted ambient-withheld event per capture, and guardrails.ambient_ttl_days stamps an expiration the read path honors; an unreadable guardrails config raises by name instead of silently falling open.

Verification

  • Local gates on the CI-pinned Bun 1.4.0 (the validate battery): bun run typecheck, bun run lint (0 errors), bun run fmt:check, bun run test - 17059 pass, 0 fail, 20 Windows-only skips; bun run scripts/sync-version.ts --check green; python3 -m unittest discover -s tests/python 199 OK.
  • Architecture gates re-ran clean after every review round: code-ranker against the merge-base baseline, the write-site/state-surface/verdict-vocabulary censuses reconciled by measurement, manifest-completeness and tool-count pins unchanged (no new MCP tool).
  • An external Opus review of this branch found 5 high, 11 medium and 9 low findings - the credential-minted identity never reaching any write-side decision, the signal lane outside the permissions document, the revoke-the-last-token anonymous re-open on a keyless network bind, a wedgeable open-decision resolution, and more - and every one was verified and fixed, alongside 21 findings against the already-shipped 1.78.0 that ride here (the wrapped store reachable from every key-bearing verb with an unwrap path, authenticated bundle metadata, content-bound approval digests, per-entry extraction contracts, the Obsidian tag grammar, the Telegram refusal loop, and the session-summary divergence semantics).
  • Self-review of the full branch with a ten-probe mutation round (one gap found and closed: the token-map-before-shared-key order is now pinned), a focused security review of the diff with a writer/reader sweep, an OpenCodeReview delegation pass at 100 percent coverage of the then-61 reviewable files (18 findings fixed), and a delta delegation over the 55 source files the external-review fixes touched (one major - a non-terminating slug disambiguation under the decisions lock - plus three minors and the fail-open enforcement drop on an unreadable token store, all fixed with regression tests).
  • The diff exceeds CodeRabbit's 100-file review limit, so bot review is skipped for this change set.

…ide-trust task 3)

mint/rotate/revoke/list/resolve over .open-second-brain/secrets/mcp-tokens.json
(0600, custody owner ACL beside secrets.json), writes under withSecretsLock,
no-values custody audit rows (mcp_token_minted|rotated|revoked), names in the
mcp_token_<slug> $secret: grammar, resolves through an mtime cache so a
rotation takes effect on the next call without a restart.
… (write-side-trust task 7)

authenticateRequest resolves the vault token map first (via: token), then the
shared key (via: shared-key, process config identity), with the unchanged
generic 401 for missing and invalid credentials; mcp_tokens_required (env twin
OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED) refuses credential-less requests once a
map exists, and the non-loopback bind accepts key or map. Identity threads as a
parameter through handleRequest -> handleToolsCall -> invokeToolHandler ->
contextFor; no instance field, so concurrent requests never observe each
other's identity. No tokens configured is byte-identical: every pre-existing
HTTP test passes unmodified.
… finding and registration (write-side-trust task 4)
Park a judgment question before the decision exists: one Markdown record
at Brain/decisions/open-<slug>.md with enumerated options, modeled on the
trigger store lifecycle - frozen status trio (open/resolved/discarded,
census-registered), JSON-quoted frontmatter, Question/Options/Context
body sections, hand-edit-tolerant parsing with named-unreadable
partitioned reads, and a directory lock around every writer under the
vault-identity guard. Dedup is the sha16 of the normalized question; a
twin question refuses naming the existing id.

resolve mints a real decision page through recordDecision (review
obligation, decision-record event and B4 trail included), stamps the
open record resolved with the [[decision-<slug>]] pointer, and lands
exactly one open-resolved receipt; discard records the reason; terminal
records stay in place so history is a status filter. Log kinds
decision-open/resolved/discarded join the Brain timeline.

Surfaces: brain_decision gains open/list_open/show_open/resolve/discard
on the existing tool (no new tool, tool-count pins unmoved); the CLI
verb mirrors them; the morning brief renders a capped, read-only
Open decisions section with unreadable records named.
One queue engine, three review lanes. Signals keep the flat
Brain/pending/sig-*.md directory byte-compatibly; note creates stage into
Brain/pending/notes/ under note- ids that carry the reversible
percent-encoding of the publish target, and ingest summary pages into
Brain/pending/ingest/ under deterministic ing- ids. The staged bytes are
byte-for-byte what the publish target would have received, so apply
reproduces the published document exactly.

- pending/pending-lanes.ts: the engine - dispositions over the
  write-approval lane keys (document-backed arm lands with the
  permissions substrate), stageForReview under the vault-identity write
  guard, reversible encode/decode round-tripped over CJK, spaces, dots
  and nested paths with the 255-byte filename bound refused by name,
  sorted listings that partition unreadable files with named reasons,
  and apply/reject whose dry runs run every check and write nothing
  (apply exclusive-creates the decoded target and refuses an occupied
  one with PendingApplyConflictError, before any unlink).
- pending.ts becomes the historical import surface: same exports, same
  sig- id grammar, engine delegated to the lanes module.
- create-note stages a first publish under the notes lane (the review
  boundary is entry, not mutation: updates, appends and occupied targets
  behave exactly as before); write-batch reports staged create ops
  per-op with the pending id; ingest stages the first publish of a
  summary page while registration completes and source cleanup removes
  a staged page like a published one.
- the CLI pending verb gains a lane column, --lane filtering and honest
  reject --dry-run previews; the MCP create/batch/feedback/ingest tools
  spread staged receipts with pending_id and the queue's next command;
  the pending-staged diagnostic registers the advisory code both use.
- the destructive-site and write-site censuses follow the engine to its
  new module; the write-gate suite stops deleting env keys it never
  saved, which leaked past its afterEach into later files.
… the preference lane (write-side-trust task 8)

integrity.owner_scope_writes joins the integrity block (off | warn | fail,
default off, unreadable config resolves to the strict fail fallback), and
src/core/brain/trust/owner-write-gate.ts pins the one pure predicate every
write lane consults: refuseCrossOwnerWrite composes the permissions
document's owner_write verdict with the gate mode most-restrictive-wins in
both directions - a document deny refuses even with the gate off, and a
document allow cannot talk a fail gate out of refusing a foreign owner.
warn passes and logs exactly one decision-ledger row carrying the gate key
as source; off with no document returns the bare verdict byte-identically,
so the explicit caller owner still wins exactly as before. Restore and
import paths (explicit owner undefined) never reach the gate.
…ed rule

When Brain/_permissions.yaml exists it is the only gate a staged-or-
published write answers to: the write-approval lane keys cannot bypass
it in either direction, exactly one substrate rule decides (target
entry, agent override, role, then default), and a stage or refuse lands
exactly one decision-ledger row whose source names the rule.

- resolveWriteDisposition consults loadPermissionsDocument and
  resolvePermission through the merged Lane A substrate: deny records
  its row and throws the new typed WriteRefusedError naming the
  principal, the action, the deciding rule and the registered exit
  (o2b brain permissions show); ask records its row and stages; allow
  publishes, with a row only when the document's ledger.record_allows
  asks for one. An unreadable document fails closed through the
  loader's own field-named error. With no document the Task 9 arm is
  byte-identical and the ledger stays empty.
- createNote resolves under the caller its write record would name and
  refuses a denied create before any byte; ingestSource resolves BEFORE
  registration, so a denied brain_ingest_source writes nothing at all -
  no entity page, no manifest row, no queue entry.
- brain_feedback refuses force_confirmed with the named
  force-confirmed-requires-allow token whenever the caller's write
  verdict is not allow, checked before any write; without a document
  the rule never fires and the tool is byte-identical.
- the MCP boundaries map the refusal to a named INVALID_PARAMS payload
  carrying the write-refused / force-confirmed-requires-allow code, the
  rule, and the next command; both codes register in the diagnostics
  registry and in the tool-error-code registry, and the refusal token
  vocabulary joins the verdict-vocabulary census.
- the write-site census gains the substrate ledger's append-only shard
  writer, which the merge brought in without its exclusion record.
…gate (write-side-trust task 13)

owner joins the refused update-frontmatter keys through the Task 8
predicate rather than the unconditionally reserved set: under
integrity.owner_scope_writes off, an update or create naming any owner
writes exactly as before; under fail, a foreign claim refuses as
owner_write_refused carrying owner-write-refused; under warn it passes
with exactly one decision-ledger row per committed write, logged at the
batch's commit so a later operation's refusal never leaves a row behind.
The create-time guard runs before the body resolves and before any
existence or staging decision, so the refusal answers the claim and
never the question of whether the target exists; in the batch kernel the
verdict is consulted at projection, so a refused create aborts before
any operation commits. A permissions document denying owner_write
refuses a note create naming any owner even with the gate off. Task
9's note, batch, pending and staged-receipt suites pass unmodified.
Task 11 of the write-side-trust wave. Two new guardrails keys in the
vault Brain/_brain.yaml guardrails block:

- guardrails.ambient_writeback (boolean, default true = today's lane).
  An explicit false withholds the whole ambient capture before any gate
  or dedup consumption: no signal is written, the dedup index is left
  untouched, and one counted, logged ambient-withheld event per capture
  records the count, session_ref and agent - never the fact text, since
  withheld content must not be captured even redacted. Absent keys keep
  the extraction lane byte-identical; a non-boolean value is a hard,
  field-named config error.

- guardrails.ambient_ttl_days (non-negative integer, 0 = no stamp).
  N > 0 stamps expiration_date = created + N on ambient-extracted
  signals through the validated writeSignal chokepoint, so filterExpired
  drops them at read; the stamp survives pending staging and applies
  verbatim. A negative or non-integer value is a hard config error.

routeExtractedFacts gains ambientWriteback/ambientTtlDays injection
seams (matching the A2/A3 pattern) and an ambientWithheld result count.
The ambient-withheld kind joins the log event vocabulary census; both
keys join the config template under the ratchet.

Also repairs two brain_decision surface pins left stale by the
open-decision vault: the tool description now fits the 300-char cap and
the pinned unknown_operation message names the five new actions.
…te-side-trust task 14)

o2b bootstrap provisions one harness in one idempotent command across the
three real install models: adapter targets (codex, grok, opencode) run the
adapter's existing idempotent apply, generic prints the payload with the
manual steps, and the plugin runtimes (claude-code, zcode) are verify-only.
--token mints mcp_token_<target> and prints the material exactly once with
a shown-once notice - never on argv, never in any harness config (the
payload env block stays credential-free) - and records a receipt at
<vault>/.open-second-brain/bootstrap.lock.json (schema 1, owned entries,
token name and non-secret prefix, applied_at). A second identical run
verifies clean and writes nothing at all; --rotate re-mints under the same
name with a replaced:true audit row and the new material authenticates on
the running server's next request; --check verifies drift from InstallEnv
alone; unsupported targets are refused with the available list. Exit codes
follow the INSTALL_EXIT table style (BOOTSTRAP_EXIT).

o2b mcp token mint|rotate|revoke|list is the named-token management
surface riding the mcp command, routed before the server starts.

Census registration for the new durable state and for the Task 3 token
store, whose write sites landed unregistered: a bootstrap-receipt state
surface row, a direct-vault-read registry entry for the receipt writer,
direct-write exclusions and a destructive-site declaration for the token
store, and the measured pin updates. Also two pre-existing type errors in
the Task 7 transport files (a null/undefined identity at the handleRequest
call site, and a deliberately out-of-vocabulary via comparison the
compiler read as impossible).

Tests: tests/cli/bootstrap.test.ts (14) and tests/cli/mcp-token.test.ts
(13), strict TDD, credential literals via fakeCredential, adapter probes
under injected runner seams with explicit 20000 ms timeouts.
# Conflicts:
#	tests/core/architecture/state-surface-census.test.ts
# Conflicts:
#	tests/core/architecture/verdict-vocabulary-census.test.ts
…ged lanes

Union of five lanes moves the measured pins: the decision ledger's
shard append joins the write-site census (direct 79 -> 80,
unstamped-direct 78 -> 79, shared-helper 113 -> 114, unstamped-shared
110 -> 111), the decisions-ledger state surface joins the population
(48 -> 49), and the vocabulary population measures 95.
…stry

Lane C's document-backed dispositions add WRITE_REFUSAL_CODES
(write-refused, force-confirmed-requires-allow) to the closed registry;
the union merge carries the vocabulary into the parity test's expected
set.
README: the control section states every new gate with its config key
and default-off posture (per-lane write approval, the permissions
document, the owner-write gate, named MCP tokens, ambient consent and
TTL, open decisions) and the What is new section names the release.

docs/cli-reference.md: the o2b mcp line names the token map beside the
shared key for the non-loopback bind, and the pending queue line names
the recall exclusion (review-pending).

docs/mcp.md: the HTTP transport section documents per-agent token
authentication and request-scoped identity; the refusal-code list gains
write-refused, force-confirmed-requires-allow and the owner-write
guard, each with its next command. No new tools - the advertised
surface and the 115-tool pin are unchanged.

docs/observability.md: a decision-ledger section (shards, row shape,
when rows land, the never-throwing append contract) plus the
decision-open/resolved/discarded and ambient-withheld log kinds.

CHANGELOG: the 1.79.0 section with the link reference. The codex
plugin's README mirror follows.
bun run build:openclaw under the CI-pinned Bun 1.4.0; the diff is the
token store joining the custody surface (tokenStorePath export, the
mcp-tokens.json custody target).
…eeing owner

The self-review mutation round showed the token-map-before-shared-key
consultation order was documented but pinned by no test: a credential
matching both resolved through whichever arm a refactor listed first.
The both-match case now pins the minted agent winning. The refusal
reason for a cross-owner claim names the token that actually disagrees
(a frontmatter owner no longer answers with an explicit-owner phrase),
and the transport documents the pre-existing tightening it already
shipped: a presented credential matching neither credential source is
refused outright instead of degrading to anonymous.
…code

The resolver header now describes the sort it actually runs - entries
target-scoped above blanket, then tightest verdict, then document order -
instead of claiming most-specific-first. The top-level mapping refusal in
the document loader was unreachable (the parser only returns mappings and
raises on anything else), and decisionLedgerShardPath took a vault
argument it never read behind a docstring naming a consumer that does
not exist.
…usal

A present-but-non-string title in an open-decision record is now a
named-unreadable entry instead of silently standing in as the id,
matching how the parse path treats every other field-type violation;
an absent title still falls back to the id as derived data. The
duplicate-question refusal no longer tells the caller to resolve or
discard a twin that may already be terminal - an open twin is resolved
or discarded, a settled one means wording the question differently.
- question and context are body prose, not structure: a line whose
  first non-space character is # would re-partition the record on
  read-back (a question carrying its own ## Options line made its
  bullets the record's options), so the writer indents such a line one
  space and the reader removes exactly that one - a byte-faithful
  round trip for every input the writer accepts.
- the decision-resolved log mirror gets the same non-throwing guard as
  its sibling mirrors: a throw after the page was minted no longer
  reports the resolve failed and skips the open_resolved receipt.
rate and outcome answered their writes through the readable-at-reach
predicate; resolve and discard skipped it, so a record below the
caller's reach leaked its existence through a success. Both
transitions now run the same predicate before anything is written and
answer a below-reach id exactly like an absent one. The title field
description also states the unnamed-<hash> fallback the ASCII-locked
slug grammar gives a non-Latin title.
The slug grammar is ASCII-locked and open hashes a title it cannot
spell to an unnamed-<hash> id; the open-decision help text now says so
instead of leaving the opaque id unexplained.
A decision-resolved mirror that throws must not fail the resolve nor
skip the open_resolved receipt; the mock throws for that one event
kind only, so the mint's own mirrors still land.
The credential-minted identity a transport resolved now rides the
ServerContext as requestIdentity (declared on the tool-contract leaf,
re-exported from the server), and every writer tool hands it down as the
permission subject the gates answer for: createNote's owner gate and
review disposition, ingestSource, writePreference's owner resolution and
gate, the write-batch subject covering the batch's create arm, and the
feedback signal and force-confirmed consults. When the identity exists
it wins over resolveAgentName; stdio, the CLI bridge and hand-built
contexts stay config-shaped.

A token caller that supplies an agent argument naming somebody else is
refused outright (agent-claim-refused): brain_feedback,
brain_ingest_source, brain_write_batch's per-op agent attribution,
brain_note, brain_expire and brain_apply_evidence all check the claim
before any byte moves, while shared-key and config-identity requests
keep the historical passthrough.

Tests speak HTTP: a document deny for the token's agent refuses the
token's create with one ledger row naming the token agent and the
deciding rule, a cross-owner claim under the fail gate is refused
answering for the token's agent, a foreign agent claim on brain_feedback
is refused before any byte, and a staged create's review row names the
token agent instead of the config identity.
writeSignal's ungated path resolved only the write-approval toggle, so a
permissions document's rules never reached the one write every feedback
surface rides through: a signal from an agent the document denies was
published, and an ask never staged. The ungated path now resolves
through the same disposition resolver every other writer answers to -
deny refuses as a typed WriteRefusedError before any byte exists, with
exactly one ledger row naming the deciding rule and the inbox path the
write intent asked for; ask stages into the signals queue with
byte-identical bytes; allow publishes, row only when the document's
record_allows asks. An unreadable document fails closed through the
loader's own error.

The subject is the transport-minted one when the caller threads it, and
otherwise the write's own agent field - already resolved against the
calling surface's config - rather than a fresh resolution that could
answer for an identity no config on this machine declared. The staging
path still never re-enters the gate it feeds.
Mutation of an already published note has no entry boundary to stage at,
so the review gate never saw it: an agent a permissions document denies
could rewrite or append to a note an allowed create once published. The
batch's update and append ops now run the deny-only document consult
ahead of the existing-note read - a deny refuses as the typed
WriteRefusedError with its one ledger row, while an ask and an allow
change nothing at all: no row, no stage, the mutation proceeds exactly
as before.

The review door leaves an audit trail: a real apply appends exactly one
decision-ledger resolution row (actor: the resolving credential when one
is handed through, else operator; source review-door; target the decoded
publish path; verdict resolved), a real reject appends its twin naming
the path the bytes will never reach, and a preview records nothing.

Staging the same target twice no longer collides or replaces silently:
stageForReview refuses a second entry whose decoded target equals an
open entry's target, naming the pending id the operator has to apply or
reject first. The comparison is on the decoded target, so the two-day
case (two ids, one target) refuses exactly as the same-day one does.
A token store that cannot be READ used to be indistinguishable from one
that cannot be satisfied: the per-request probes threw, the exception
escaped the dispatch as a 500, and whether the gate then stood open
depended on which probe blew up first. Both probes are now failure-netted
per request: the failure is named on stderr (the store's own reason, once
per read), the presented credential fails the way any unmatched one does
- the generic 401, no oracle - and a matching shared key still answers,
because a corrupt store must not lock the operator's master credential
out of its own server.

The exposed-bind posture no longer reads the map's current readability as
the requirement either: a key-less non-loopback bind enforces by itself,
so a map that turns unreadable (or empties) under a running server cannot
re-open anonymous access that bind never promised. A map already corrupt
at bind time fails the bind's own credential-source check, closed.
…stics cycle

The disposition module resolved its refusal's registered exit at import
time, and the module sits inside the diagnostics import cycle: next-step
reads the diagnostics registry, which reaches the doctor checks that
reach the signals parser and this module back. Depending on which entry
point loaded first, the module-scope requireNextStep call ran while the
registry was still initializing and crashed with a TDZ ReferenceError -
the doctor inbox check and the write-binding suite triggered it, and
nothing but import order stood between every other suite and the same
crash. The exit now resolves on first refusal, memoized; the registry
census still pins the code's registration at test time.
…agnostics cycle

Routing the disposition module's requireNextStep call through the
registry closed a cycle the acyclic-dependency ratchet refuses:
next-step reads diagnostics, diagnostics reaches the doctor checks, and
the doctor's signal checks reach the signals parser and the disposition
module back - so the module graph carried a 121-file strongly connected
component, and the TDZ crash the lazy resolution had dodged was only
postponed. The exit command now lives in a leaf importing nothing, the
same cure entities/canonical.ts documents: the registry builds both
write-side trust entries from it and the disposition layer reads it
directly, keeping the string at exactly one definition, with a census
pin holding leaf and registry together.
Windows reports every file mode as 666 and resolves environment names
case-insensitively at the OS level, and absolute paths carry the
platform separator, so the restore-mode, env-case and staged-path
assertions now pin their POSIX halves where the platform honours them
and normalize separators where they compare paths.
A title whose slug fills the 64-character cap made every derived mint
candidate slugify identically: derivedMintTitle appended the
distinguishing record id at the END of the title, while slugify
truncates from the START, so the tail was silently dropped. When the
bare slug was occupied by a foreign decision page, the mint walk spun
forever holding the decisions-directory lock, wedging every later
transition.

The derived candidates are now built on a mint head - the longest
word-prefix of the title whose slug stays within 40 characters - with
the record's own id pre-shaved to ride INSIDE the cap, and the numbered
candidates keep their counter inside the cap too, so successive
candidates slugify distinctly and the walk terminates by construction.
SLUG_MAX_LEN is exported from vault.ts so the budget reads the cap it
budgets against. The provenance convergence check is unchanged on every
candidate.
…s for any reason

The shown-once rescue print fired only in the InstallError branch, so a
PayloadError from the payload build or a non-InstallError throw after a
mint still lost the material forever: the credential stayed live in the
store while nothing ever showed it again (a re-run mints nothing). The
payload build and the apply now sit in one try/catch whose every exit
prints the material exactly once, on stdout, beside a failure notice
naming the retry and revoke paths, before the usage refusal or the
rethrow.
…code

PendingStageConflictError - a create or ingest retried while its first
run is still staged - was unmapped at the MCP surface, so the retry
surfaced as a generic internal error a caller would retry into the same
wall. The notes and ingest tools now answer it beside their
WriteRefusedError handlers: the pending-stage-conflict code carrying the
queue entry holding the target and the target itself, registered in the
error registry, its test oracle and the docs catalog. The error carries
its code like WriteRefusedError does.
…'s configPath

The document consult's no-subject fallback resolved resolveAgentName()
without the caller's configPath, so on an explicit-configPath run the
consult answered for a different principal than the owner gate (which
honors opts.configPath): a document rule naming the config agent could
fail to refuse - a fail-open - and the ledger row named the wrong actor.
The fallback now resolves through ResolveWriteDispositionOptions
.configPath, threaded from the write-batch update and append consults
and the create-note disposition; callers that carry no config path keep
the ambient fallback. The signal and ingest surfaces always hand a
resolved subject, so their rows were never wrong.
…re is unreadable

With mcp_tokens_required configured and a token store that could not be
read, the per-request probe answered no tokens and enforcement dropped:
a credential-less request was waved through exactly when the operator
had asked for the opposite. The probe's catch now treats the map as
non-empty whenever enforcement was requested (the config key or a
key-less exposed bind), so the request is refused with the generic 401
and the read failure stays named on stderr; the shared key keeps
answering, so the operator's master credential is not locked out.
escapeBodyProse overclaimed byte-faithfulness: a question that OPENS
with an already-indented heading-shaped line reads back dedented,
because the section reader's own trim strips the leading whitespace -
the writer's escape with it - before the dedent runs. The claim now
scopes to the lines the trim leaves alone and names the exception.

The permissions-agent-denied doctor finding names its exit through the
WRITE_REFUSAL_NEXT_COMMAND constant, the same shape as the two refusal
findings below it, instead of repeating the literal.
…also fails

The read-back catch called writeRawKeyfileBytes bare, so a restore that
itself failed replaced the original error: the caller saw the restore's
fault instead of the read-back failure that named what actually went
wrong first. The restore now runs inside its own guard - its failure is
demoted to a named stderr warning beside the original refusal, which
propagates unchanged.
…te itself

Hoisting the rescue print into a helper moved the retry and revoke
literals into a variable argument, out of the process.stdout.write call
text the forward-pointer rail reads, so the sanctioned sites measured
zero and the rail went blind to pointers that still reach stdout. The
shared retry and revoke tail is spelled on the write itself again; the
per-branch failure clauses name no invocation.
@itechmeat
itechmeat merged commit 3f02924 into main Oct 11, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants