Story-3287 · step envelopes · pramaan-functions

One presigned URL becomes N, and nothing else changes

Blake processes a 400-page bundle in one AgentCore run and writes exactly one object at the very end. Counsel watches a spinner for several minutes, a crash at member sixteen throws away members one through fifteen, and nothing downstream can react until the whole thing lands. ADR-180 breaks that open: after every step Blake drops that step's result to S3, S3 rings the bell, and the broker consumes the drops as they arrive.

For Blake to write sixteen objects he needs sixteen places to write them. That is this ticket, and it is the smallest possible piece of ADR-180: the broker mints N per-step presigned PUT URLs instead of 1. No consumer, no status table, no knife, no brain change — those are #3288 through #3292.

Blake's credential posture does not move an inch. He still holds no S3 key and no SDK. He receives capabilities; he never acquires them. The change is the number of capabilities on the envelope, which is why this ticket is small and why it must not grow.

What "identical constraints" had to mean

The ADR amendment says each step envelope sits "under the existing single-envelope constraints." That sentence can be honoured two ways: copy the constraints into a second minting function and promise they match, or make it one function that both paths call. Only the second one is still true a year from now, so the guard, the signing session, the expiry and the presign call were pulled out of mint_result_delivery into two shared helpers before a single line of N-way code was written.

_guarded_signing_client(deps, pending) -> (s3_client, expires_in)
_presign_put(deps, pending, s3_client, key=..., expires_in=...) -> url

mint_result_delivery  -> 1 x _presign_put   (the terminal key)
mint_step_envelopes   -> N x _presign_put   (one loop, one key per step)

So "the constraints are identical" is not a review promise; it is the same code executing. The test that proves it does not read the source — it mints a terminal envelope and a step envelope through stub S3 clients and compares the two generate_presigned_url call dictionaries term by term, popping only Key, which is the one thing that is supposed to differ.

One signing session for the whole run

A run signs once. The D9 mint guard measures the remaining lifetime of the signing credentials against budget + slack + write allowance, and that number is a property of the run, not of a step — so all N envelopes carry the same expiry and cost one AssumeRole between them. A seat that cannot deliver is refused before any credential is touched, exactly as before; N does not change what the guard decides, only how many URLs come out the far side.

The key grammar is the interface with three other tickets

runs/<run>/steps/<n>-<step>[.member-<m>].json

runs/3333.../steps/1-carve.json
runs/3333.../steps/2-classify.member-3.json

This string is load-bearing in three repos at once. pramaan-infra (Chore-3288) filters its EventBridge rule on prefix runs/ and suffix .json; the router (Story-3290) validates key against body; pramaan-agents writes to it. So it is built by exactly one function, build_step_drop_key, never by a caller's f-string, and every segment of it is the broker's — the same rule that already governs the terminal key. A step name is checked against the closed four-name set for the same reason seat is slug-checked: a step called ../up would walk the key out of its own run prefix.

The failure this design has to refuse

Duplicate descriptors are the interesting rejection, and they are worth being precise about. Two identical (step, member) pairs mint two different keys, because the index differs. Both are live capabilities. Blake writes to both. The consumer then sees ONE logical drop arriving as TWO events with two different ids — and the whole once-only application design in amendment §4 keys on drop_id, which is precisely the thing that cannot save you here, because the two arrivals are genuinely distinct drops as far as any downstream dedupe can tell. The damage is done at mint. So it is refused at mint, before any credential is assumed, with a typed error that names the offender.

RefusedWhy
the same (step, member) twicetwo keys for one logical drop; no downstream dedupe can undo it
an empty step listminting is per-run; a run with no steps has nothing to deliver
more than 64 entriesblast radius — every element is a live bearer capability
carve naming a membercarve PRODUCES the member list; it cannot be about one
an unknown step name, a negative memberthe descriptor cannot be constructed at all

The same-step-two-members case is not a duplicate — a main member and an annexure both get carded — and there is a test for that sitting right next to the duplicate test, because a rejection rule that is too greedy breaks the fan-out this whole epic exists to enable.

N=1 is today, byte for byte

Two separate claims hide under that acceptance box, and they need two separate proofs.

  1. The terminal block did not change. The shared base class refactor could have widened result_delivery by accident, and the brain parses that block with extra="forbid" — a fourth field is an instant contract break. So its wire dump is pinned against a literal JSON string.
  2. Routes with no step list are byte-unchanged. pydantic would happily serialise "step_envelopes": null onto every Atlas turn in the fleet. The envelope's serialiser pops the key when it is None, exactly as it already does for intent, agent_run_id and result_delivery, and the test asserts absence rather than nullity.

N bearer secrets, N times the discipline

Every envelope's URL is a SecretStr: reprs mask it, logs never carry it, and the plain value escapes only through JSON serialisation onto the invoke payload. The list is stamped as a sibling of input, so no prompt-assembly path can reach it — Blake reads adversarial documents all day, and a bearer URL inside a prompt is a capability handed to whoever wrote the PDF.

The leak test is not a grep of the source. It drives all three logging seams on the step path — a refused list, a failed presign, a successful dispatch — and then asserts that no emitted line contains any URL, any signature fragment, any session token, or even the URL's path without its query string. Where a capability points is itself a leak; the log formatter's redaction is the backstop, not the licence.

The span that deliberately does not exist

Amendment §7 ratifies three new span names for the consumer surface. Minting gets none of them, and that is a decision, not an oversight: it is a pure in-process credential operation sitting on the invoke's own critical path, and a span per step per run would carry nothing the invoke span does not already say.

A written exemption is a comment, and comments do not fail. So the exemption is a booby trap instead: the test swaps manual_span for a function that raises, mints three envelopes successfully, and then — the planted negative — points the same trap at dispatch_artifact_run and watches it fire. If someone adds an unratified span to the mint, that test is what says no.

The refusal seams are still not silent (R12): each one emits its own structured agent_delivery.outcome census line and raises a typed error. step_list_refused joins the existing six.

What is deliberately not here

Not in this PRWhere it lives
anything that consumes a dropStory-3290 — a sibling worktree of this repo, right now
the EventBridge rule and the queueChore-3288, pramaan-infra
the carver invocation that builds a real step listStory-3289
Blake writing to these URLsP4, pramaan-agents, gated on Spike-3271

Which means mint_step_envelopes ships with no production caller today — the same state mint_result_delivery has been in since Story-2981, and the honest answer to review-gate question 7 is that Story-3289 wires it. The terminal result_delivery block also stays, unchanged and alive: ADR-180 makes step drops additive on purpose, and deleting the terminal path before the brain stops writing to it would take Blake down. That deletion belongs to P4, in P4's PR.

How this was verified red

Four reverts were planted into the shipped code and the suite was run against each: the duplicate check replaced with pass, the None-pop deleted from the serialiser, the .member-<m> segment dropped from the key, and the parse-boundary validator short-circuited. Each one turned the matching test red and only the matching test — 1, 1, 2 and 1 failures respectively — then the file was restored and all 46 went green again. A test that has never been observed failing is not evidence.