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
| Piece | Where |
|---|---|
| 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
| Decision | Why 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.
| Claim | The 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
-
The created/edited verb can be mislabelled on a genuine dead
heat. The service reads before it writes to choose the audit
verb; the WRITE is arbitrated by
ON CONFLICT, so no write is lost and noIntegrityErrorescapes — but a loser that read "no row" lands as an UPDATE while its audit row sayscreated. The cost is one wrong word in the trail on a simultaneous first save by two admins. Locking the row would meanFOR UPDATEon a row that does not exist yet, which is the Bug-3235 shape. -
updatedByon an identical resave is the original author, not the clicker. A resave of identical text touches no row and writes no audit entry, so reporting whoever pressed save would contradict the very next GET — response and stored row disagreeing about one field, with only one of them being the record (Codex on PR #926, P2). The response now returns the storedupdated_by. - The style guide is not a re-enrichment trigger. Editing it changes the pin, so future runs are unstyled-or-restyled correctly, but already-written summaries keep the old voice until something re-drives them. D23 answers this explicitly — "a re-summarisation question, answered the same way as tonight's other re-enrichment calls: accept on staging."
Where the seams are
-
Storage — migration 0244,
agents.registrar_personalisation; Core table insrc/shared/agents_db_models.py -
Read / stamp / pin —
src/shared/registrar/personalisation.py -
Classify envelope + pin —
build_registrar_invoke_payload,_runtime_session_id -
Card envelope + pin —
build_card_invoke_payload,run_card_pass -
Settings door —
src/app_api/routes/firm.py,RegistrarPersonalisationService -
Proofs —
tests/unit/test_registrar_personalisation.py,tests/unit/test_registrar_personalisation_migration.py,tests/integration/test_registrar_personalisation_rls.py,tests/integration/test_app_registrar_personalisation.py, plus the end-to-end pairs intests/unit/test_document_registration_consumer.pyandtests/unit/test_context_card_pass.py -
Spans — none added. ADR-124 exemption: the routes ride
the T1 auto-instrumented FastAPI span, and the broker-side read and
stamp ride the caller's existing
agent.invoke.build. What R12 asks of the seam is an outcome LINE, and it emits one on every call.