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.
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.
| Refused | Why |
|---|---|
the same (step, member) twice | two keys for one logical drop; no downstream dedupe can undo it |
| an empty step list | minting is per-run; a run with no steps has nothing to deliver |
| more than 64 entries | blast radius — every element is a live bearer capability |
carve naming a member | carve PRODUCES the member list; it cannot be about one |
| an unknown step name, a negative member | the 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.
-
The terminal block did not change. The shared base
class refactor could have widened
result_deliveryby accident, and the brain parses that block withextra="forbid"— a fourth field is an instant contract break. So its wire dump is pinned against a literal JSON string. -
Routes with no step list are byte-unchanged. pydantic
would happily serialise
"step_envelopes": nullonto every Atlas turn in the fleet. The envelope's serialiser pops the key when it isNone, exactly as it already does forintent,agent_run_idandresult_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
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 PR | Where it lives |
|---|---|
| anything that consumes a drop | Story-3290 — a sibling worktree of this repo, right now |
| the EventBridge rule and the queue | Chore-3288, pramaan-infra |
| the carver invocation that builds a real step list | Story-3289 |
| Blake writing to these URLs | P4, 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.