Repository navigation
feat: write-side trust - identity, permission and review for every agent write (v1.79.0) - #247
Merged
Merged
Conversation
…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.
…ite-side-trust task 1)
…ds (write-side-trust task 2)
… (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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 --> GWhat changed
o2b mcp token mint|rotate|revoke|listkeeps 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.--api-keystays 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.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,--rotatere-mints under the same name effective next request,--checkverifies drift, and the check path honors the receipt and unreachable-verdict contracts.Brain/_permissions.yamlresolves allow/ask/deny per agent, role and target-scoped entry for thewrite,ingestandowner_writeactions -default_actionrequired, 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.Brain/logs/decisions/, naming actor, action, target, verdict and the deciding rule;o2b brain permissions show|ledgerreads them ando2b brain doctorreports an unreadable document by name.Brain/pending/, batch create ops stage per operation withpending_idreceipts, and the staged documents are excluded from search-index admission so recall cannot surface what no operator has admitted.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;warnallows with exactly one ledger row, and a document composes most-restrictive-wins with the gate in both directions.o2b brain decision open|list_open|show_open|resolve|discardparks a question with enumerated options atBrain/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.guardrails.ambient_writeback: falsesuppresses ambient extraction with one countedambient-withheldevent per capture, andguardrails.ambient_ttl_daysstamps an expiration the read path honors; an unreadable guardrails config raises by name instead of silently falling open.Verification
validatebattery):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 --checkgreen;python3 -m unittest discover -s tests/python199 OK.