Skip to content

feat(streams)!: the bearer key is an ADR-0031 provisioning need, minted per streams module#92

Open
wmadden-electric wants to merge 28 commits into
mainfrom
claude/streams-minted-key
Open

feat(streams)!: the bearer key is an ADR-0031 provisioning need, minted per streams module#92
wmadden-electric wants to merge 28 commits into
mainfrom
claude/streams-minted-key

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What an app author writes

// module.ts — the deploy root
const store = provision(storage());
const events = provision(streams(), { deps: { store: store.store } });
provision(jobsService, { deps: { events: events.streams } });
// src/jobs/service.ts — the consumer
export default compute({
  name: 'jobs',
  deps: { events: durableStreams() },
  build: node({ module: import.meta.url, entry: '../../dist/jobs/server.mjs' }),
});

// src/jobs/server.ts — the binding hydrates to a ready client
const { events } = service.load(); // StreamsClient

await events.create('jobs');
await events.append('jobs', { kind: 'created' });
const { events: log, nextOffset } = await events.read('jobs');
const next = await events.tail('jobs'); // resolves on the next live event

Nothing anywhere authors the key, a URL, a header, or a protocol detail: no secret slot, no envSecret, no bearer scheme, no offset names, no long-poll dance. Declaring the dependency is the wiring, and hydration hands back a typed client — the RPC parity (rpc() hydrates through makeClient; streams hydrates through createStreamsClient). Previously the root bound one platform variable into the module's apiKey secret slot and into every consumer's own slot, connected only by convention, and the app hand-rolled every request.

Decision

The bearer key is a value the framework mints, which is exactly what ADR-0031 is for: durableStreams()'s apiKey param declares a provisioning need under a streams brand, and the prisma-cloud target registers a provisioner for that brand. It is not an ADR-0029 secret (no external value, nothing to name) and not a producer output (no producer hands it over).

The provisioner is per-provider: it reuses #93's ServiceKey resource with the provider's address as the resource id, so every consumer edge of one streams() module resolves to one resource and one stable value — which is what @prisma/streams-server requires, since it authenticates a single API_KEY. ADR-0031 explicitly leaves cardinality to the provisioner, so this is policy, not a workaround.

Consistency is the reason this shape won. An earlier revision of this PR minted the key with its own BearerKey resource + a streams node descriptor. That worked, but it stood a second, streams-shaped mechanism next to the framework's general one — two ways to audit one concept, and one more thing a maintainer must learn per module. A module should build on the framework's internals rather than invent its own.

What's in the diff

  • streams-keys.ts (new, target): the brand STREAMS_API_KEY, the need constructor, the faceted-edge scan, and the reserved env name — mirroring service-keys.ts, with its same no-effect/no-@internal/lowering constraint.
    • Brand-location deviation, on purpose: ADR-0031's discipline is that the declaring package owns the brand and the target imports it (what @internal/rpc does). prisma-cloud's layer order is lowering → extensions → modules, so a target import of @internal/streams would invert the layering. The brand lives in the target and the module imports it downward instead; the writer/reader-share-one-key discipline is unchanged. Documented at both ends.
  • streamsApiKeyProvisioner registered in the target's provisions map beside serviceKeyProvisioner — same ServiceKey mint, keyed on edge.providerAddress. No new resource kind.
  • Provider landing (ADR-0019): compute's serialize writes the minted value to COMPOSER_<addr>_STREAMS_API_KEY; compute's run re-stashes it address-free — the same channel shape RPC's accepted set uses. The streams entrypoint reads it there.
  • Zero consumers = no key. The need lives on the consumer's edge, so a streams() nothing depends on never gets a key minted, and the server's only alternative would be serving every endpoint unauthenticated. It refuses to boot with a named error instead. (A deploy-time check isn't cheaply expressible: after the descriptor deletion the streams service is a plain compute(), and nothing in the target can tell it apart without re-adding the streams-shaped surface this PR removes. The deploy-side half — no consumers → no mint, no landing — is pinned by a target test.)
  • Deleted (vs. this PR's earlier revision): bearer-key.ts, bearer-key-resource.ts, descriptors/bearer-key.ts, descriptors/streams.ts, streams-compute.ts, their registrations, exports, arch-config entries and tests. streams-service.ts is a plain compute() again; the module provisions no credentials resource.
  • createStreamsClient / StreamsClient (@internal/streams, exported via the umbrella): the client the binding hydrates to — create (ensure-style) / append / read / tail, wrapping @durable-streams/client (ElectricSQL's canonical protocol client, Apache-2.0, pinned 0.2.1 — the version @prisma/streams-server 0.1.11's own repo pairs with; pure fetch-based JS inlined by tsdown, so no package grows a runtime dependency). Platform compensations live here with their tickets: tail() long-polls (PRO-218) via the protocol's own offset=now; idempotent operations ride out a cold start with a bounded backoff (PRO-219); appends never retry — the wire client's default retries network errors indefinitely on every method including POST, so the writer handle pins maxRetries: 0 and batching: false (no idempotency key upstream). The factory is exported standalone so local dev wraps the stand-in URL without load().
  • examples/streams collapses to app logic: routes, the stream name, error mapping, and calls on the hydrated client — zero protocol knowledge in userspace.
  • examples/streams keeps its jobs consumer service (append / read / long-poll tail over the binding), which exercises the key in-deployment and removes the zero-consumer shape from the example.
  • The jobs consumer carries no retry, deliberately. It calls the streams service with a plain fetch; a failure surfaces as a 502 naming its cause. Cold-start handling belongs in the platform, not in every consumer — see the Verification section and PRO-219.
  • Conformance suite pinned to exact 0.2.3 (reverting dependabot chore(deps-dev)(deps-dev): Bump the dev-deps group across 1 directory with 4 updates #86's 0.3.5 bump): 0.3.x tests fork/TTL/sub-offset features @prisma/streams-server 0.1.11 doesn't ship — 60/332 failed locally on main before this. The suite must track the pinned server version, not float; both harness headers record why.

Verification

Proven live on real Prisma Cloud (stack streams-example, deployed then destroyed — re-run on the client-hydration commits):

  • Deploy green — 29 resources (counted from destroy; the deploy log double-reports steps). One streamskey-streams.service mint, landed to the consumer's param and the provider's reserved var.
  • The consumer service through its own public URL, all protocol via the hydrated client: POST /jobs → 201 (first request, cold deploy); GET /jobs → 200 with events and a nextOffset; GET /jobs?offset=<mid> → the opaque-cursor resume returns exactly the later events; GET /jobs/tail → the client's long-poll delivered an event appended after it opened. Auth negatives direct to streams: unauthenticated → 401, junk bearer → 401.
  • A real integration defect was caught by this live run and fixed: the deployed server's offset=now long-poll answers 204 with no content-type, defeating the wire client's JSON-mode detection (invisible against the stand-in, which sends the header). The client now passes the json: true hint — streams are JSON by this module's contract.
  • Deployed conformance: 215/239 — all 24 failures the known "SSE Mode" group (PRO-218); zero protocol failures. Smoke 8/9 (same single SSE item). Harness key read from deploy state (mode-600, deleted after).
  • Key stability across a redeploy: [streamskey-streams.service] noop, key byte-identical in state, consumer green with all prior events intact.
  • Destroy clean: 29 resources, both URLs 404, the Project reaped by the CLI (ac1e7b1).
  • One first-request-after-idle POST returned 502 (the deliberately non-retried append meeting a cold instance) — PRO-217/PRO-219 surfacing exactly as the example intends.

Local:

  • @internal/streams 18/18 — including the new client suite against the stand-in (create-ensure semantics, append/read round-trips, opaque-cursor resume, live tail delivery + clean timeout, and a real protocol error surfacing unretried) and the entrypoint integration test end to end.
  • @internal/prisma-cloud 188/188 (incl. feat(rpc): auto-provision per-binding RPC service keys (ADR-0030/0031) #93's and docs(rpc): document service keys; deny zero-consumer providers; close the project #100's regression nets and the boot-sweep/landing tests, each checked against a planted defect); local conformance 239/239 (raw-fetch harness — it tests the server, not our client); examples/streams 6/6 (the app driven through the client against the stand-in).
  • Entrypoint and testing bundles verified single-file (no external imports beyond bun/node: builtins) with the wire client inlined; build / typecheck / test:types green; repo pnpm lint green; lint:deps clean; cast ratchet delta 0.

Alternatives considered

  • Hydrate to the raw { url, apiKey } config and let apps hand-roll the protocol (this PR's earlier revision). Rejected by the operator: Durable Streams protocol logic in a user application is the moral equivalent of RPC users doing their own request encoding. The client library is also the right home for the platform compensations (PRO-218's long-poll default, PRO-219's idempotent-only retry) — in userspace they were boilerplate; in the library they are policy.

  • A module-owned BearerKey resource + a streams node descriptor (this PR's earlier revision). Rejected on consistency: it duplicated ADR-0031's job with streams-specific surface. The narrower technical comparison only ever showed it was possible, never that it was right.

  • Per-edge keys now. Needs @prisma/streams-server to accept a key set (it authenticates one API_KEY). When upstream grows that, this becomes a provisioner cardinality flip plus an accepted-set landing — no resource to add, no core change, nothing here to delete. Recorded in design-notes.md.

  • External (out-of-deployment) key access: the same recorded platform ask as storage's minted SigV4 credentials; test harnesses read deploy state instead.

🤖 Generated with Claude Code

@pkg-pr-new

pkg-pr-new Bot commented Jul 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/composer@92
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@92

commit: 0ca79a0

@wmadden-electric
wmadden-electric force-pushed the claude/streams-minted-key branch from b29db7d to 96b3f90 Compare July 16, 2026 10:02
@wmadden-electric wmadden-electric changed the title feat(streams)!: the bearer key becomes a minted binding credential (ADR-0030) feat(streams)!: the bearer key is an ADR-0031 provisioning need, minted per streams module Jul 16, 2026
Comment on lines +112 to +123
// Provider side, streams' need (ADR-0031): the upstream server
// authenticates a single API_KEY, so its provisioner mints ONE value
// per provider and every inbound edge's ref must be that same key.
// This landing writes one value, which is only correct while that
// holds — so assert it rather than trust it: a future per-edge flip
// without the paired accepted-set landing would otherwise ship the
// wrong key silently. The refs are lazy Outputs here (not comparable
// at serialize time), but inside Output.map they are resolved
// strings, which is the same seam RPC's accepted set aggregates on.
const inboundStreams = streamsApiKeyEdges(graph).filter(
(e) => e.providerAddress === address,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this? Why does a particular node need magic logic in the compute() base?

@wmadden-electric

Copy link
Copy Markdown
Contributor Author

Answering the review comment on descriptors/compute.ts — you and I crossed: that comment is anchored to b6d0f7a, and the fix landed in 6f98f16 about 10 minutes later.

You were right, and it was worse than a streams problem. compute imported two brand modules and carried two hardcoded provider-landing blocks — RPC's (from #93) and streams' (ours). Every future provisioned-param brand meant another import and another block in the general descriptor: the accretion ADR-0031 exists to prevent, relocated from core into the target. And ADR-0031 already assigned the fix — "the provisioner owns mint, size, aggregation, stability, and rotation" — so the landing never belonged in compute at all. We inherited the shape from #93 and doubled it.

6f98f16 deletes it rather than relocating it:

  • descriptors/compute.ts runs one brand-blind loop — it looks a landing up by the need's brand and never names one. Each brand's landing lives beside its provisioner in control.ts; RPC's aggregation moved there verbatim (same env key, same encoding, its tests untouched as the regression net).
  • The two hand-written edge scanners collapsed into one generic provisionedEdges(graph) that returns the brand as data.
  • src/compute.ts had the identical accretion, so its re-stash went generic too.
  • Core is unchanged (ADR-0031: the physical landing stays the target's); the seam is target-local, passed down as data to avoid a compute → control cycle.
  • The acceptance test: a third brand requires editing none of descriptors/compute.ts, src/compute.ts, or the scanner — only registering a provisioner and a landing in control.ts.

Grep on the current head: zero occurrences of RPC_PEER_KEY|STREAMS_API_KEY|service-keys|streams-keys in either compute file.

Now rebasing onto main, which moved under this: #100 made RPC's landing provider-driven (zero consumers → literal "[]" → deny all), while our loop is edge-driven — rebased naively that would silently reopen the fail-open hole #100 just closed, so the seam is going provider-driven (each registered landing invoked per exposing service with possibly-empty refs; the landing decides — RPC emits "[]", streams emits nothing so its server fails closed by refusing to boot). Re-proving live afterwards, since #94/#100/ac1e7b1 all landed since the last live evidence. Will re-request review when it's green.

🤖 Generated with Claude Code

@wmadden-electric
wmadden-electric force-pushed the claude/streams-minted-key branch from 6294c1b to 73039df Compare July 16, 2026 12:42
…inding credential (ADR-0030)

The streams binding becomes { url, apiKey }: the key is minted once at
deploy and delivered to consumers on the same rail as the URL, mirroring
storage's minted-credential machinery end to end. The module's apiKey
secret slot — and the "consumer binds the same platform variable twice"
convention it forced — disappear.

New in @internal/prisma-cloud, each the s3-credentials/s3-store template
applied:
- BearerKey Alchemy resource (PrismaCloud.BearerKey): a 48-hex key minted
  on first create, returned unchanged on every later apply, so redeploys
  no-op. Named distinctly from the rpc-service-key project's reserved
  per-edge ServiceKey — this is a module-level, single-key credential
  (the upstream server auths one API_KEY).
- bearerKey(): the dual-form authoring factory (resource with { name } /
  dependency without), contract kind "bearer-key", binding { apiKey }.
- streamsDescriptor: compute's service lowering with extended outputs —
  serialize/deploy surface apiKey from the wired credentials resource and
  fail closed when it is unwired.
- streamsCompute(): compute with the routing type overridden to "streams",
  exactly s3StoreService / "s3-store".

The streams package rewires onto it: the module provisions its own
bearerKey({ name: "credentials" }) and wires it into the service (deps
{ store, credentials }, secrets: none); the entrypoint reads API_KEY from
load().credentials.apiKey; the contract carries { url, apiKey }; the
integration test drives COMPOSER_CREDENTIALS_APIKEY (a dep param) instead
of the old secret pointer. examples/streams drops envSecret — nothing to
bind at the root anymore.

Also pins @durable-streams/server-conformance-tests to 0.2.3: dependabot's
0.3.5 bump (#86) tests fork/TTL/sub-offset features the wrapped
@prisma/streams-server 0.1.11 does not implement, failing 60/332 locally
on main before this change; the suite must track the pinned server
version, not float.

BREAKING CHANGE: streams() no longer has an apiKey secret slot; consumers
read the key from the durableStreams() binding.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… rationale

README and SCOPE now describe the ADR-0030 model: the binding is
{ url, apiKey }, the key is minted at deploy and stable in deploy state,
and the wiring example loses the consumer secret slot and the
bind-the-same-variable-twice convention — the consumer reads the key from
load(). Both keep the single-key-per-module-instance note: per-edge keys
(rpc-service-key slice 2) need an upstream accepted-key-set change and are
recorded as future work. The "Deployed live path: use long-poll" section
stays as is (PRO-218).

Both conformance harness headers now record why the suite is pinned to
exact 0.2.3: later 0.3.x versions test features @prisma/streams-server
0.1.11 does not ship, so a floating range fails conformance for reasons
unrelated to this module.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ped mint

#93 landed the generic per-edge provisioning rail (ProvisionNeed + the
target's provisions registry) while this branch was in review. Compared
and kept the provider-scoped BearerKey design: core's provision semantics
mint one value per consumer→provider EDGE, and @prisma/streams-server
authenticates a single API_KEY — per-edge values cannot apply until the
upstream server accepts a key set. The rebase resolution keeps #93's
provisions registry and ServiceKeyProvider intact alongside this branch's
bearer-key/streams registrations.

Folded in:
- streams-descriptor tests pass graph.edges (compute's serialize now scans
  RPC edges via serviceKeyEdges — #93's ctx shape).
- architecture.config.json entries for bearer-key.ts / streams-compute.ts
  (shared plane) and bearer-key-resource.ts (control plane) — matching how
  #93 registered service-keys.ts, so the plane rules actually see them.
- README/SCOPE future-work note now records the concrete migration path:
  upstream accepted-key-set PR, swap durableStreams()'s apiKey param to a
  ProvisionNeed with a registered streams provisioner, delete the
  module-level mint (ADR-0031).
- design-notes.md § "Streams consumer auth — settled by ADR-0030"
  (stranded on the merged predecessor branch, re-landed here) extended
  with the ADR-0031 comparison outcome.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…esign session)

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…R-0031 rework

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…not a module resource

Reverses this branch's own mechanism. The BearerKey resource + `streams`
descriptor were a second, streams-shaped way to mint a param value beside
ADR-0031's general one. A module must build on the framework's internals
rather than invent its own: two ways to audit one concept is one too many.
Consistency is the whole reason — the resource route was never wrong, just
redundant.

- `durableStreams()`'s `apiKey` param now declares a ProvisionNeed under
  STREAMS_API_KEY. The brand, the edge scan and the reserved env name live
  in the target's `streams-keys.ts` (mirroring `service-keys.ts`), NOT in
  the declaring package as RPC does: prisma-cloud's layer order is
  lowering → extensions → modules, so a target import of @internal/streams
  would invert it. The module imports the brand downward instead.
- The target registers `streamsApiKeyProvisioner` under that brand: the same
  `ServiceKey` mint as RPC, keyed on the PROVIDER's address rather than the
  edge id, so every consumer of one streams module resolves to one resource
  and one stable value — what @prisma/streams-server's single API_KEY needs.
  ADR-0031 leaves cardinality to the provisioner, so this is policy, not a
  workaround.
- Provider landing (ADR-0019): compute's serialize writes the minted value
  to COMPOSER_<addr>_STREAMS_API_KEY and its `run` re-stashes it address-free,
  exactly like RPC's accepted set. The entrypoint reads it there instead of
  from a `credentials` dep, and refuses to boot with a named error when it is
  absent — no consumers means no minted key, and the only alternative would be
  serving every endpoint unauthenticated.
- Deleted: bearer-key.ts, bearer-key-resource.ts, descriptors/bearer-key.ts,
  descriptors/streams.ts, streams-compute.ts, their registrations, exports,
  arch-config entries and tests. streams-service.ts is a plain compute()
  again; the module provisions no credentials resource.
- examples/streams gains a `jobs` consumer service (append / read / long-poll
  tail over the binding), which exercises the key in-deployment and removes
  the zero-consumer shape from the example.

Verified: target 161/161, streams 13/13 (incl. the entrypoint integration
test over the landed key), local conformance 239/239, example 5/5,
build/typecheck/test:types green, lint:deps clean, cast ratchet 0.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The landing writes ONE key, which is only correct while the provisioner
mints per provider. Taking the first inbound ref trusted that silently: a
future per-edge cardinality flip without the paired accepted-set landing
would have shipped whichever key came first and left every other consumer
401ing, with nothing failing.

The refs are lazy Alchemy Outputs at serialize time, so they cannot be
compared there — but inside Output.map they are resolved strings, which is
the same seam RPC aggregates its accepted set on. Assert distinctness there
and throw a named error that says which invariant broke and the two ways to
fix it (mint per provider, or land an accepted set once the server takes
one).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ongs to the provisioner

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… through its brand's registered landing

compute had one hardcoded provider-landing block per brand, plus an import
of each brand's module. Every future provisioned param would have added
another of each, in the one descriptor that must stay general — the exact
per-brand accretion ADR-0031 exists to prevent, relocated out of core into
the target. #93 established the shape for RPC and this branch doubled it;
mirroring it was never a justification.

ADR-0031 already assigns the fix — "the provisioner owns mint, size,
AGGREGATION, stability, and rotation" — so aggregation moves out of compute
and next to each provisioner:

- `ProvisionLanding` (descriptors/shared.ts): given a provider's inbound
  refs for one brand, return the reserved env name and the aggregated value.
  Registered in control.ts beside that brand's provisioner, and handed to
  the descriptors as data on ResolvedCloudOptions, so control.ts stays the
  only file that names a brand and no import cycle forms.
- `provisionedEdges(graph)` (new): the ONE scan, carrying each edge's brand
  as data. Replaces the two hand-written per-brand scanners — recognising a
  brand is a map lookup, never a branch.
- compute's serialize: group this address's inbound provisioned edges by
  brand, hand each group to its landing, write what comes back. It imports
  neither brand module and names no brand. A brand with no landing needs
  nothing provider-side (core fills its consumers' params), so it is skipped.
- RPC's landing migrates onto the seam too — the leak is not deleted while
  its first instance remains. Its JSON accepted-set aggregation and
  RPC_ACCEPTED_KEYS name move to `serviceKeyLanding`; streams' single-value
  landing and its keys-must-agree assertion move to `streamsApiKeyLanding`.
  Same env keys, same encodings — #93's tests are the regression net.

The runtime half had the same accretion (a re-stash per brand): boot now
re-keys this address's whole reserved namespace address-free by prefix,
derived through configKey so it cannot drift from what deploy wrote, before
the typed stashes that stay authoritative for params and secret pointers.
That drops the last two brand imports, from src/compute.ts.

No deploy-observable change: same env var names, same values, same encodings
— only where the code that computes them lives.

Verified: target 162/162 (incl. #93's RPC provisioning tests untouched),
streams 13/13, local conformance 239/239, example 5/5, build/typecheck/
test:types green, lint:deps clean, cast ratchet 0.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The sweep is what makes every address-free reader work, and nothing
exercised it through run(): the entrypoint integration test sets the
address-free key directly and never boots through compute. Four tests,
each verified against a planted defect:

- a LANDING var at this address IS aliased address-free, with compute
  naming no brand (fails when the sweep is removed, and when it is made
  address-blind).
- a sibling's var is NOT aliased — including the sharp case of a service
  nested UNDER this address, whose var starts with this sweep prefix.
  Anchoring the prefix is what makes a foreign var structurally unable to
  produce this address's key.
- stash() wins over the sweep's raw copy for a declared param (fails when
  the sweep is moved after the typed stashes — the ordering the comment
  claims, now pinned).
- an empty address is a no-op sweep; the address-free row stands.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The live re-proof's first POST after a redeploy returned 500: ensureStream()'s
PUT hit the streams service mid-cold-start, the fetch rejected, and the throw
became an opaque 500. As a teaching artifact the example was demonstrating the
wrong lesson — a consumer of a scale-to-zero service WILL meet it cold
(PRO-217, already in gotchas.md).

Did NOT reuse @internal/prisma-cloud/connection's retryTransientConnect: it is
not a public export, and examples may import only the 9-public packages
(ADR-0028, `examples-import-public-only`) precisely so they stay honest demos
of what a user can write — a user writing this app could not import it either.
Its predicate is also Postgres vocabulary, and the HTTP case needs transient
STATUSES (502/503/504), which a predicate over thrown errors cannot express.
So: a small, obvious retry local to the example, not an HTTP retry library.

- `fetchIdempotent`: bounded backoff (4 attempts, 250ms→1s) over the
  cold-start class only — socket failures and 502/503/504. Used for the PUT
  and the reads.
- Appends stay un-retried, deliberately: a write that may have reached the
  server cannot be blindly repeated (gotchas.md's own guidance). ensureStream()
  runs first on every path, so it is the first touch that absorbs the cold
  instance and the append meets a warm one.
- A dependency still unreachable past the retries now answers 502 with the
  cause, instead of an opaque 500.

Both new tests were checked against planted defects: dropping the retry fails
the cold-start test, and treating any non-ok as transient fails the 401 test.
A proxy in front of the real stand-in injects the failure, so the retry is
exercised through the app rather than against a mock of itself.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The comment claimed ensureStream() had already waited out a cold instance so
the append meets a warm one. That is only true for the FIRST append —
`created` is memoized, so a later one can still meet a cold service and
surface 502. Narrow, and it fails closed, but in an example the comment IS
the lesson, so it has to be true.

Now states the real reason appends are not retried (no idempotency key, so a
failure is indistinguishable from an applied write) and what actually
follows: the first append is shielded by the retried PUT, a later one can
surface 502, and the caller retries because only it knows whether a duplicate
is acceptable.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The old ~1.75s budget was a plausible-looking constant, and it was under the
real thing: the streams entrypoint takes ~3.5s from VM start to listening on
a small object store and ~8s on a larger one (measured from Compute version
logs, spark start -> "prisma-streams server listening"). So the example
demonstrated the opposite of its lesson — a cold dependency took three manual
attempts.

Now a 30s budget with exponential backoff (250ms doubling to an 8s cap),
expressed as a deadline so the number IS the budget: covers the observed
worst case with room, still gives up rather than hanging on a dependency that
is genuinely down, and is well inside the framework's own 60s cold-start
retry for the same class of problem on Postgres (FT-5226). The comment cites
the measurement rather than folklore.

Proven live: against a genuinely fresh streams instance (a new version
promoted the moment it reported running), the FIRST POST /jobs returns 201 in
3.69s — riding out the boot the old budget would have thrown on.

The non-retried append and its comment are unchanged.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric force-pushed the claude/streams-minted-key branch from 73039df to 53bb085 Compare July 16, 2026 13:15
@wmadden-electric
wmadden-electric requested a review from wmadden July 16, 2026 13:20
Reverses D8/D11. The retry was mine, not the evidence's, and interrogating
it did not survive contact:

- The budget was sized against the boot time (3.5-8s), but no fetch here is
  time-bounded — the deadline bounds the SLEEPS. If the edge holds the
  connection through a cold start, which PRO-217 says it does on most hits,
  a plain fetch blocks and returns 201 and the budget never applies.
- The 201-in-3.69s proof cannot distinguish one held request from four
  retries; I did not capture the service's logs and cannot say which.
  Either way the old budget would have passed the same test, so the tune
  was justified by a number that was not measuring what I claimed.
- The observation that motivated the tune — "three attempts after a
  redeploy" — was a misdiagnosis: those 502s were the deliberately
  non-retried append, which no budget governs.
- The retried cold-start STATUS (502/503/504) was never observed from the
  platform at all. It came from my own proxy stub, written to match my
  assumption.

What is left is real but not the app's job: an intermittent thrown socket
close (PRO-217), already filed. Absorbing it here costs ~45 lines of
platform-specific boilerplate in a teaching artifact, cannot cover the
non-idempotent append where it was actually observed, and hides the defect
from the people who would fix it. So: plain fetch. The top-level guard stays
(a 502 naming the cause beats an opaque 500 — ordinary hygiene, not
Prisma-specific), and the example's first request after an idle spell may
now intermittently fail. That is the honest state of the platform.

The synthetic cold-503 test goes with it — it asserted my assumption, not the
platform. The 401 test stays and still catches removal of the guard.

gotchas.md's PRO-217 entry is extended with what this slice learned: the
thrown-close face and its ~400ms fast-fail, the measured ~3.5-8s boot window
from the version logs, that no edge 502/503/504 face was ever seen, that it
landed on both an idempotent PUT and a non-idempotent append, that idling is
an unreliable trigger and bodies must be captured to tell whose 502 it is,
and PRO-219 for the userspace-boilerplate cost.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ot edge intent

"How long the edge must hold" asserted a platform contract nobody made;
the entry now states the two observed outcomes and that the caller cannot
predict which. Matches the PRO-219 rewrite.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Comment thread examples/streams/src/jobs/app.ts Outdated
Comment on lines +20 to +38
/** Edge statuses that mean "the service isn't up yet", not "your request was wrong". */
const COLD_START_STATUS = new Set([502, 503, 504]);

/**
* The streams service scales to zero, so the first call after an idle spell
* meets an instance that is still booting: the connection can reset
* mid-establish, or the edge answers 502 while it comes up (Prisma Compute,
* PRO-217 in gotchas.md).
*
* The 30s budget is measured, not folklore: the streams entrypoint takes
* ~3.5s from VM start to listening on a small object store and ~8s on a
* larger one (Compute version logs), so this covers the observed worst case
* with room and still gives up rather than hanging on a dependency that is
* genuinely down. The framework's own cold-start retry allows 60s for the
* same class of problem on Postgres (FT-5226).
*/
const COLD_START_BUDGET_MS = 30_000;
const FIRST_BACKOFF_MS = 250;
const MAX_BACKOFF_MS = 8_000;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't belong in userspace

Comment on lines +447 to +453
// The boot sweep (compute.ts's `restashAddressFree`) is what makes every
// address-free reader work — config, secrets, serve()'s accepted keys, and
// any landing's reserved var (the streams entrypoint's API_KEY is the second
// one). It cannot be replaced by writing address-free at deploy: one project
// is one env namespace, so two services would collide on COMPOSER_PORT.
// Only boot knows which address it is.
test("run() aliases a LANDING's var at this address address-free, with no knowledge of the brand", async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does any of this jargon mean

…am check)

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ol logic leaves userspace

An app consuming streams was hand-rolling the Durable Streams protocol: URL
layout, bearer scheme, JSON-array append framing, offset conventions, the
long-poll dance. RPC users don't do their own request encoding (rpc()
hydrates through makeClient); streams users shouldn't either.

New `createStreamsClient({ url, apiKey })` in @internal/streams wraps
@durable-streams/client (ElectricSQL's canonical protocol client, Apache-2.0,
pinned 0.2.1 — the version @prisma/streams-server 0.1.11's own repo pairs
with; pure fetch-based JS, no node:/bun tokens, inlined by tsdown so no
package grows a runtime dependency). The surface is what the module contract
promises — create / append / read / tail — not everything Electric ships.
Platform compensations live here, each with its ticket:

- Auth from the binding: the bearer header rides every request kind (the wire
  client resolves headers per request, including the polls).
- tail() long-polls — SSE cannot traverse the Compute ingress (PRO-218) — and
  uses the protocol's own offset=now, so there is no client-side head dance.
- Idempotent operations (create/read/tail) ride out a cold start with a
  bounded backoff (PRO-219). Appends NEVER retry: the wire client's default
  retries network errors indefinitely on every method including POST, so the
  writer handle pins maxRetries: 0 and batching: false (no idempotency key
  upstream — a failed append is indistinguishable from an applied one).

durableStreams() now hydrates to the client (the wire binding stays
{ url, apiKey }; hydration returns StreamsClient). The factory is exported —
and via the umbrella — so local dev wraps the stand-in URL without load().

The example collapses to app logic: routes, the stream name, error mapping,
and calls on the hydrated client. No URL building, no bearer header, no
offset names, no long-poll dance in userspace. Its integration test drives
the app through the client against the stand-in; a new client test covers
create-ensure semantics, append/read round-trips, opaque-cursor resume, live
tail delivery and clean timeout, and that a real protocol error surfaces
unretried. Conformance harnesses stay raw fetch — they test the server, not
our client.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…without a content-type

Found live, invisible against the stand-in: the deployed server's
offset=now long-poll answers 204 with no content-type, so the wire
client's JSON-mode detection failed and tail() threw "JSON methods are
only valid for JSON-mode streams". This module's streams are JSON by
contract, so read() and tail() pass the wire client's `json: true` hint
instead of depending on a header.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The 401 no-retry test exercises a 4xx, which the wire client throws BEFORE
its retry branch at any maxRetries — so deleting NO_RETRY_BACKOFF (or
batching: false) regressed silently to infinitely-retried, batch-coalesced
appends with every test green. Two tests through a counting proxy close
that:

- A 503 on an append (503 IS in the wire client's retry set) rejects after
  exactly ONE POST. Removing NO_RETRY_BACKOFF makes the append resolve on
  the proxy's second POST instead — verified red.
- Five concurrent appends are five POSTs. Removing batching: false lets the
  wire client coalesce the in-flight window into shared POSTs (2 arrive,
  and a failure would be ambiguous across callers' events) — verified red.

Also corrects the IDEMPOTENT_BACKOFF comment to what the wire client
actually does: it retries any failure except a 4xx other than 429 — thrown
network errors AND 5xx statuses — and the bound is attempts, not wall-clock
(each wait jittered, a server Retry-After acting as a per-wait floor,
capped upstream at 1h).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ms backoff

The FT-5226 cold-connect canary's contract, applied to the Compute face of
the same disease: a CI job that passes only while the bug is still present,
and goes red as the signal to remove the workaround it guards —
createStreamsClient's IDEMPOTENT_BACKOFF (the PRO-219 compensation).

Shape: the deploy-verify-destroy action over examples/streams, with the
canary as its verify step — A (jobs) appends to B (streams) un-retried, so
what the caller sees on B's first touch is the raw platform behavior. Each
sample forces a FRESH B by promoting a new version of the deploy's own
content-addressed artifact (idling is an unreliable trigger, per the
gotcha), then probes across the switchover window (0/2.5/5s — routing to
the new instance is not instant, so a single immediate touch can land on
the old warm one and read as a hold it never earned). Judged with the
FT-5226 unanimity rule: any close proves the bug; all-held is evidence,
not proof, and says so.

Two flaws the first live rounds caught, fixed:
- fresh instances bootstrap from the object store, and a sample taken
  before the warmup's stream had uploaded (5s seal interval) restored a
  world without it — every touch 404'd. The canary now waits out the
  durability window after warmup.
- the 404s exposed a real app bug: jobs' memoized create meant a stream
  lost from the durable tier bricked the instance permanently. The app now
  heals — a 404'd operation provably applied nothing, so re-create and
  retry once is safe even for the append. Covered by a test that deletes
  the stream out from under the app (verified red without the heal).

Observed today, fresh workspace: 0 closes across 9 fresh starts (17
probes) — historically the close was frequent (D7/D10/D13 all hit it).
Either the platform improved or the trigger differs; exactly the question
the canary now keeps asking on every run.

gotchas.md's PRO-217 entry gains the removal-guard pointer, and
IDEMPOTENT_BACKOFF's comment names the canary as its removal trigger.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…b reality

The entry still described the pre-client-lib example ("no retry, plain
fetch") two lines above the removal guard for exactly that retry. It now
says where the compensation actually lives (createStreamsClient, idempotent
operations only), why appends stay un-retried, and what the canary guards.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…nt lib

The jobs example healed a vanished stream by duck-typing status === 404 on
the wire client's undocumented error shape — the last trace of wire-client
knowledge in userspace. `isStreamNotFound(error)` now lives in the client
module, where that shape is already a known dependency (an instanceof check
against the wire client's own error classes, exported beside
createStreamsClient and via the umbrella), and the example uses it.

Semantics unchanged, deliberately: exactly the applied-nothing 404 —
ambiguous failures (socket closes, 502/504) never match, which is what the
heal's retry-once safety proof rests on. The healing test still passes and
still goes red without the heal (re-checked).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric requested a review from wmadden July 17, 2026 08:07
…signal

The canaries are becoming REQUIRED checks: the build must fail when a
workaround exists with no problem, and must not fail for any other reason.
Both canaries move from pass/fail to a three-state verdict with the exits a
required check needs:

- bug-present → exit 0 (today's normal; the job stays green),
- bug-gone (conclusive all-clean) → exit 1 — the forcing signal. The message
  is written for whoever meets it cold on an unrelated PR: it says the
  failure is not caused by their change, and lists the full cleanup — the
  workaround (createStreamsClient's IDEMPOTENT_BACKOFF / pg-connection.ts's
  withConnectionRetry), the canary files and workflow job, the gotchas.md
  removal-guard text, and the ticket to close (PRO-219 / FT-5226).
- inconclusive (mixed, timeouts, 404s, broken canary) → exit 0 plus a
  ::warning:: annotation carrying the verdict and per-sample detail — loud
  on the run page without blocking every PR on a deploy flake. Previously
  inconclusive exited 1, which a required check cannot afford.

Both classifier test suites pin the three-exit mapping, including that the
bug-gone message names every artifact the cleanup touches.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ontract

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Comment on lines +50 to +57
const append = async (req: Request): Promise<Response> => {
const event = await req.json();
// The client never retries appends (no idempotency key upstream — a
// failed request is indistinguishable from one that applied). The caller
// retries, because only it knows whether a duplicate is acceptable.
await withStream(() => events.append(STREAM, event));
return Response.json({ appended: event }, { status: 201 });
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the user's application doing this? I would have expected STREAM was a property of the durable-stream connection DI'ed into the service.

And why is await withStream() necessary in applciation code, instead of in the hydrated stream client?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there value in separating this app.ts from server.ts?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand, what changed in this file? Just indentation?

* every registered landing about every exposing service and writes whatever
* comes back — returning `undefined` writes no row.
*/
export type ProvisionLanding = (input: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm deeply suspicious of this - what is it?

* the descriptors never import a brand's module (and so control.ts, which
* owns both registries, stays the only place a brand is named).
*/
readonly provisionLandings: ReadonlyMap<symbol, ProvisionLanding>;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a hack.

Comment on lines +114 to +123
// Re-key THIS service's whole reserved namespace address-free, before
// the typed re-stashes below: every reader downstream (config, secrets,
// serve()'s accepted keys, the streams entrypoint's API_KEY) looks its
// var up with no address, because one instance runs one service. Doing
// it by prefix keeps this brand-blind — a landing's reserved name is the
// registered landing's business (control.ts), never something compute
// has to know (ADR-0031). Only this address's own vars move; the typed
// stashes that follow overwrite anything they own, so they stay
// authoritative for params and secret pointers.
restashAddressFree(address);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this mean? What does "restash address free" mean? why would we want this? Why is this permitted in compute code?

Comment on lines +40 to +42
* Copies `COMPOSER_<address>_<rest>` to `COMPOSER_<rest>` for every var of this
* service's address. Derives both names through `configKey`, so it cannot drift
* from what deploy wrote.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Woah what? why???

);
}

export function createStreamsClient(config: StreamsConfig): StreamsClient {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convert to a class. It's an object with a bunch of methods, stop forcing factory functions in the wrong paradigm

Two settled designs, recorded before any slice spec/plan per operator
direction: provider-side minted values become declared reserved params
(deleting restashAddressFree), and the streams contract carries named
streams whose handles own the lifecycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants