Story-3292 · D23 firm style guide · pramaan-functions

Ship the field empty, and make the emptiness say so

Blake writes exactly two pieces of prose: a document's summary (the card pass) and its description (classify). D23 — the founder's decision of 2026-08-02, recorded on Blake's design lens — says a firm may write standing instructions that shape how those two read. "Summaries in three short sentences, dates first, British spelling." Nothing else moves: quotes and dates and parties are evidence (D12), tags and labels are governed vocabulary (D21), privilege and significance are ruled judgments (D13). One line of law: style may change HOW it reads, never WHAT it says.

This Story does not implement that. It ships the FIELD, empty, so the P4 brain ticket reads storage that already exists instead of inventing it inside a prompt change. There are no prompt changes here, no fence, and no adversarial corpus — those are D23's own conditions on the surface going live, and they belong to the ticket that turns it on.

The acceptance that mattered most is a negative one. Every firm in existence has no style guide today, so every envelope this touches must leave the process byte-identical to yesterday's — and it must say, in the log, that it went out unstyled. A branch that quietly does nothing is exactly the shape root AGENTS.md R12 bans: a trace that ends early is indistinguishable from one that finished.

What ships

PieceWhere
Storage — one row per firm, RLS firm-scoped migration 0244, agents.registrar_personalisation
The read, the stamp and the pin — one module, one seam each src/shared/registrar/personalisation.py
The two prose passes src/workers/document_registration_consumer/processor.py (classify) · src/shared/context/card_pass.py (card)
The settings door — editors/admins write, everyone reads GET/PUT /app/firm/registrar-personalisation, RegistrarPersonalisationService
Audit — one row per real edit registrar_personalisation.created / .edited; the guide's text is content class and never enters a snapshot

The four decisions

DecisionWhy not the obvious alternative
One row per firm, UNIQUE (firm_id) D23 fences this to Blake's two prose fields and explicitly defers per-matter-type variants. A (firm_id, agent_key) shape would encode a generality the decision refuses — and the sibling per-COUNSEL store (agents.counsel_preference) already carries the agent axis for the surface that needs it. The uniqueness is also what the writer's ON CONFLICT conflicts on, so two concurrent first saves resolve into one row instead of racing through an application-side existence check (R14).
style_version is STORED, not derived at each read It is a broker-side PIN input, read by two call sites in two processes. A value recomputed independently at each is a value they can disagree about. It is a hash, so it carries no prose — which is why it is safe in a log line, an audit snapshot and a session id, and the text is in none of those.
Twelve hex characters, not a full digest The pin is concatenated into an AgentCore runtimeSessionId, which is length-bounded. Indigo's prompt_version is the same sha256[:12] shape; a 64-character digest would crowd the id for no added identity.
DELETE is revoked for the app role Clearing the guide writes an empty block. The row is the audit trail's subject anchor, and "this firm cleared its style guide" is a different fact from "this firm never had one" — a delete collapses the two.

The pin (D14)

Both prose passes address AgentCore with a DETERMINISTIC runtimeSessionId, so a redriven duplicate re-uses one provider session and its sticky prefix. D14's measurement (Spike-3114) is that Blake's real caching win is the implicit prefix cache — the card seat already reads 4,864 of 7,342 prompt tokens from it, free, by putting stable instructions first. The style block is firm-stable, so it belongs in exactly that prefix.

Which is the problem: a run under an EDITED guide must not continue the session the OLD guide primed. So the version joins the pin.

no guide   registrar-{document}-{extraction}                    83 chars
           registrar-card-{document}-{extraction}               88 chars

a guide    registrar{document.hex}{extraction.hex}-{version}    86 chars
           registrarcard{document.hex}{extraction.hex}-{version} 90 chars

An unstyled firm — every firm today — gets back exactly the string the rail used yesterday, character for character. That is the same rule as the envelope, applied to the idempotency key.

The styled form drops the hyphens, and that is not cosmetic. AgentCore caps runtimeSessionId at 100 characters (botocore bedrock-agentcore SessionId: max 100, pattern [a-zA-Z0-9][a-zA-Z0-9-_]*). The card base is already 88, so a plain {base}-{version} is 101 — one over. Every styled card invocation would be refused at the wire, swallowed by run_card_pass's never-raises contract as card_pass_failed, and delivered to a human as a document with no card and no explanation. UUID hyphens are fixed-position padding: dropping them is deterministic, loses no identity, and buys 11 characters where the pin costs 13. It is applied to every styled id rather than only the one that overflows today — a rule that changes shape on a length coincidence is a rule nobody can predict — and there is no compatibility cost, because no firm has a guide yet. The trade: grep a styled run by the hyphen-less document id.

This was caught in review, not by the tests as first written (Codex on PR #926, P1). The original assertion checked len(...) < 100 on the classify pin — which fits at 96 — and certified the card green without ever computing it. The replacement is parameterised over BOTH live bases and checks the length AND AgentCore's own pattern; reverting the fix turns the [card] parameterisation red and leaves [classify] green, which is exactly the gap.

The silent-no-op rule, made mechanical

stamp_registrar_style is the single seam. It writes the two keys when there is a guide and touches nothing when there is not — and in BOTH cases it emits one structured line naming the outcome (inlined / absent) and the mode it applied to. The alternative — each caller remembering to log its own absence — is how you get one of the two passes silently unstyled six months from now.

{"msg":"document_registration_consumer.registrar_personalisation",
 "mode":"classify","outcome":"absent","document_id":"…","run_id":"…"}

{"msg":"context.card_pass.registrar_personalisation",
 "mode":"card","outcome":"inlined","style_version":"73270c9fba8e","block_chars":66}

The prose itself is never logged. The version is the identifier that makes the line actionable anyway, and the text is counsel-typed content.

Named honestly: this is a new injection surface

D23 says so itself. Counsel-typed text entering the prompt of an agent whose corpus assumed documents were his only untrusted input is a new attack surface, and D23's conditions for turning it on are a style-only fence with the law stated inside it, plus a personalisation-injection case family in the adversarial corpus (RFD-SEC-002). None of that is here, and that is deliberate — nothing in this Story lets the block reach a model. The field is empty for every firm, the brain has no slot to read it into, and the two envelope keys are additive fields Blake's BaseModel inputs currently ignore. Turning it on is the P4 brain ticket's job, gated on its own conditions.

Where the block sits on the wire

{
  "schema": "agent_invoke.v1",
  "agent_key": "record_registrar",
  "mode": "card",
  "meta":  { … },
  "input": {
     …,
     "registrar_personalisation": "Summaries in three short sentences…",
     "style_version": "73270c9fba8e"
  }
}

Inside input, beside matter_context — read context, never an instruction — and nowhere near result_delivery or step_envelopes, which are bearer secrets under ADR-180 D18 and are siblings of input for exactly that reason.

What was watched go red

No green counts until it has been red. Every claim below was run against a deliberately broken tree first.

ClaimThe break, and what went red
An empty firm's envelope is byte-identical stamp_registrar_style made to write {"registrar_personalisation": None} on the absent branch — the shape a next author reaches for. Both test_absent_style_leaves_the_envelope_byte_identical parameterisations failed. The assertion is an ORDERED key tuple captured from main, so a null placeholder and a reordering both fail it.
The absence is logged, never silent The absent branch's logger.info deleted — all four absence/stamp tests failed.
The version joins the pin style_pin made to return its argument unchanged — the two pin tests failed, on both passes.
Cross-firm reads are refused firm_isolation_policy DROPped on the live test database — all three RLS tests failed, including the WITH CHECK half (firm B planting a row into firm A).
The app role cannot delete the row GRANT DELETE … TO pramaan_app re-issued — test_the_app_role_cannot_delete_the_row failed.
Every write leaves an audit row The await emit(…) removed from the service — two of the three API-boundary tests failed.
A styled session id is one AgentCore accepts style_pin reverted to the plain {base}-{version} the review caught — the [card] parameterisation of test_every_live_styled_pin_is_a_session_id_agentcore_accepts failed at 101 characters while [classify] stayed green, plus the two pin-shape tests.
updatedBy survives an identical resave the no-op branch reverted to returning the calling user — test_an_identical_resave_by_another_editor_keeps_the_original_author failed.
Writes need settings:write The permission dependency removed from the PUT route — test_write_needs_settings_write_but_read_does_not failed. The denial is driven by a real firm_member membership resolved through identity.role_permissions, not by a permission list the test hands itself.

Two limits, stated

Where the seams are