Field Note 68current
The frontend is three packages
Three packages, three reasons to change: a transport-agnostic client, headless hooks, components. Step-up auth is the test — the client returns the challenge as data, a host in context resolves it, the caller contributes intent and observes progress, never the mechanism.
My Personal LLM Policy: Extract, not generate
The thinking is mine, whether it’s years old or from this week. What a model does is get it out of my head and onto the page — writing time I’d otherwise never spend, not substance I didn’t have. I read every line, I can defend any sentence, and the errors are mine: the same bar I hold everything here to, model or no model.
TL;DR
Ship three packages, not one. A TypeScript client that knows the protocol and nothing about React. Headless hooks that turn it into React state. Components that render. The layering is not cosmetic — each layer has a different set of people depending on it and a different reason to change.
- The base client is the protocol, in the product’s vocabulary, with no React and no DOM. If it cannot run in Node, in a test, or from a cron job, it is not a base layer — it is a UI library wearing an SDK label.
- The headless layer owns everything React makes hard: subscription, cancellation, out-of-order
responses, invalidation after a write. It exposes concepts, not endpoints —
useSignIn(), neverusePost('/v1/sign_ins'). - Components are the layer people replace. Build them as the best example of using the hooks, never as the only route to them.
- The interruption test decides whether the layering is real. Anything that stops mid-call and
needs a person — a second factor, a step-up, a consent screen, a device approval — is where naive
layering breaks. The client returns it as a typed, resumable value, because a headless client has
nothing else it could honestly do. Not a thrown error, not an
onNeedsMFAcallback. - Above that, the caller must not have to handle it. No branching on an MFA case, no control-flow handler registered at the call site. A cross-cutting interaction host, mounted once in application context, owns every challenge kind. The hook hands the challenge to it, and the call’s promise stays pending until it is satisfied.
- Transparent is the baseline, not the ceiling. A caller can subscribe to what is happening —
refreshing credentials, awaiting a second factor — and can contribute the intent the dialog should
show, because the call site is the only layer that knows what is being authorised. It supplies
content and observes status; it never owns the mechanism.
create(body, { intent })keeps the layering,create(body, { onNeedsMFA })breaks it. - Agonize over the middle boundary (ZFN-66Field Note · currentZFN-66 — Agonize over the interface, not the choice behind itRe-implementing a decision now costs hours. Changing an interface costs everyone standing on it. Spend deliberation at the boundary; hold the choice behind it loosely — on one condition: you know a decision was made, and where it lives. Unnoticed ones are the expensive kind.Why it's cited here: The reason the middle layer is where deliberation goes. The hook signature is the boundary with people standing on it; the client behind it and the components above it are both cheap to redo.Open ZFN-66 →). The client behind it and the components above it are both cheap to redo; the hook signature is the one with strangers standing on it.
Context
Plenty of engineers who would never let an HTTP handler open a database connection will happily
write a React component that calls fetch, parses the response, owns the retry policy, holds the
error state and renders a spinner. The same person, the same week, with a completely different
standard applied on each side of the network.
Part of why is that React is a good enough state container to feel like the architecture. It isn’t.
It is the rendering layer, which happens to have state in it. Give a component useState and
useEffect and it will cheerfully absorb the protocol, because nothing stops it and the first
version works.
The idea has an ancestor. Presentational and container componentsDan Abramov — Presentational and Container Components (2015)The post that named the split between components that fetch and hold state and components that render. Abramov added an update in 2019 withdrawing the recommendation: he no longer suggests dividing components this way, because hooks separate stateful logic without the arbitrary wrapper. He was careful that what he retracted was the class-era pattern, not separation of concerns.medium.com ↗ named a version of this split in 2015, and Abramov withdrew the recommendation in 2019 — hooks made the wrapper component an arbitrary division, and he was right that it was. But what he retracted was a split by shape: does this component have markup in it. The split worth keeping is by who depends on it and what makes it change:
- the client changes when the API changes;
- the hooks change when the product’s concepts change;
- the components change when the design changes.
Three different rates, three different audiences. Collapsing them means a redesign touches your retry logic, and an API version bump touches your markup.
The cost lands hardest if you ship an SDK to anyone. Somebody will want your protocol and none of your components — they have a design system, or a native app, or a framework you have never heard of. If the only way to get your protocol is to mount your UI, they will reimplement it from the network tab, badly, and then you own a support burden for a client you did not write and cannot version.
Recommendation
The base client
Plain TypeScript. Generate it from the schema wherever you can (ZFN-14Field Note · currentZFN-14 — Define every API with a schema, and generate the clientsDefine every API with a machine-readable schema (OpenAPI, Protobuf, GraphQL) as the source of truth, and generate clients and server stubs from it — never hand-roll request-building and JSON parsing. Hand-written clients drift and break silently; check schema compatibility in CI.Why it's cited here: Why the base client is generated rather than written. A protocol hand-typed into a client is a second, divergent definition of the protocol.Open ZFN-14 →) — a protocol hand-typed into a client is a second definition of the protocol, and the two will drift.
- Take a credential provider, not a credential. An async function that returns a token, supplied at construction. Not a React context, not a module global. That single decision is what lets the same client run in a browser, on a server, in a test and in a job.
- Speak the product’s nouns. The client owns retries, idempotency keys, the error taxonomy
(ZFN-58Field Note · currentZFN-58 — Errors are part of the contractError paths are the half of your API clients depend on most, and usually the half nobody designed. Enumerate error codes in the schema like any other type: stable code, retryable-or-not, whose fault, structured params. Machines branch on codes — anyone parsing prose is broken.Why it's cited here: The argument this note applies to step-up. A challenge is the protocol working, so modelling it as an exception is the same mistake as an untyped error.Open ZFN-58 →), deadlines and
AbortSignal(ZFN-61Field Note · currentZFN-61 — Propagate the deadlineEvery request has a deadline whether you set one or not — the caller's patience. Make it explicit at the edge, carry it as remaining budget on every hop, check it before expensive steps, and cancel downstream when it dies. Work past the deadline is the fuel of cascading collapse.Why it's cited here: Cancellation as a first-class part of the call, which is what an unmounting component needs from the layer beneath it.Open ZFN-61 →). - Own the session as one object with one refresh in flight. Not seven hooks racing to refresh the same expired token.
- No React import, enforced by a rule. A lint rule or a dependency check, in CI. The invariant is directional and it lasts precisely as long as something checks it — someone in a hurry will violate it once, and after that it is structural.
The headless layer
This is the real API, and the layer you cannot take back once people build on it. Spend the deliberation here (ZFN-66Field Note · currentZFN-66 — Agonize over the interface, not the choice behind itRe-implementing a decision now costs hours. Changing an interface costs everyone standing on it. Spend deliberation at the boundary; hold the choice behind it loosely — on one condition: you know a decision was made, and where it lives. Unnoticed ones are the expensive kind.Why it's cited here: The reason the middle layer is where deliberation goes. The hook signature is the boundary with people standing on it; the client behind it and the components above it are both cheap to redo.Open ZFN-66 →).
- Expose the product’s concepts.
useSignIn()returning{ status, next, submit, error }, wherestatusincludes the interrupted states as first-class values rather than as an error string somebody has to match on. - Subscribe to client-owned state with
useSyncExternalStoreReact — useSyncExternalStoreThe hook for subscribing a component to a store that lives outside React. Takes `subscribe`, `getSnapshot`, and an optional `getServerSnapshot` for SSR and hydration. Its reason to exist is tearing: under concurrent rendering, components reading an external store by other means can render against different versions of it within a single commit.react.dev ↗, rather than copying it intouseStatefrom an effect. Copying gives you two sources of truth, and under concurrent rendering it gives you tearing — two components in one commit rendering different versions of the same session. - Own the concurrency problems. Cancel on unmount. Resolve out-of-order responses by request identity rather than arrival order. Invalidate after a mutation — and if your API returns a version token, wait on that instead of refetching and hoping (ZFN-25Field Note · currentZFN-25 — Track the version a client has seen for read-your-writesFor read-your-writes across backends, track the latest version a client has seen — a token or vector clock. Return it on write; reads then go to a backend at least that fresh. Hold it client-side (a token they present) or server-side (a gateway tracks the session and routes).Why it's cited here: What the hook layer should wait on after a mutation. A version token beats refetch-and-hope for knowing when a write is visible.Open ZFN-25 →).
- No DOM, no styling, and no strings a designer would want to change. If a hook returns “Please enter your verification code,” the hook is doing the component’s job and someone’s translation file is about to acquire a very strange entry.
The components
- Composition over configuration. A
<SignIn />with forty props is the layering failing quietly: every prop is somebody asking for a change you should have let them make by dropping to the hook. - Ship them as the reference implementation. Document the hook path as a first-class way to use the library rather than an escape hatch, because the people taking the escape hatch are usually the ones who need you most (ZFN-31Field Note · currentZFN-31 — Own your components — when you deeply understand the domainOwning your own components rather than generic off-the-shelf services is often the better path as you grow: own what's core, lean on small vetted libraries for the hard parts. LLMs make it attainable at smaller scale — but only when you truly understand the domain, or it hurts.Why it's cited here: Why the components have to be replaceable. The people who replace them are the ones who need you most.Open ZFN-31 →).
- Accessibility and internationalisation live here, and nowhere else.
Interruptions that need a person
A call at the base layer — client.account.delete(), client.transfers.create() — can come back
saying not until this person proves who they are again. There are three ways to model that, and
two of them quietly destroy the layering.
- Throw it. Now a second-factor request is an error, and every caller writes a catch block that reconstructs a flow from a string. But nothing went wrong: the server asking for stronger authentication is the protocol working exactly as designed (ZFN-58Field Note · currentZFN-58 — Errors are part of the contractError paths are the half of your API clients depend on most, and usually the half nobody designed. Enumerate error codes in the schema like any other type: stable code, retryable-or-not, whose fault, structured params. Machines branch on codes — anyone parsing prose is broken.Why it's cited here: The argument this note applies to step-up. A challenge is the protocol working, so modelling it as an exception is the same mistake as an untyped error.Open ZFN-58 →).
- Call back into the UI. Pass
onNeedsMFAinto the client and let it open a modal. The base layer now knows about UI, so it cannot be called from a test, a job, or an agent (ZFN-38Field Note · currentZFN-38 — Agents are principals: delegate, never impersonateAn agent acting with a copied user credential is impersonation — untraceable by design. Give agents their own identities and keys; let them act for a human only through explicit, scoped, time-bounded, revocable delegation; and record both actor and principal on every action.Why it's cited here: The caller that kills the callback design. A script or an agent has no modal to open, and an SDK whose only step-up path is UI cannot serve one.Open ZFN-38 →) — none of which has a modal. This inverts the dependency in the exact direction the layering exists to prevent. - Return it. The call resolves to a discriminated union: the result, or a challenge — a typed value naming what is required, what it is for, and a token to resume with. A component can render it. A test can satisfy it programmatically. A script can refuse it and say clearly why it stopped.
The protocol already does it the third way. RFC 9470RFC 9470 — OAuth 2.0 Step Up Authentication Challenge ProtocolStandards Track, September 2023. Lets a resource server answer a request with 401 and `WWW-Authenticate: Bearer error="insufficient_user_authentication"`, carrying `acr_values` for the assurance level it wants and `max_age` for how recent the authentication must be — a machine-readable statement of what would make the call succeed. The token and introspection response carry `acr` and `auth_time` so the client can tell whether it has been satisfied.rfc-editor.org ↗ has the resource server answer
401 with WWW-Authenticate: Bearer error="insufficient_user_authentication", plus acr_values for
the assurance level it wants and max_age for how fresh the authentication has to be. That is a
machine-readable statement of what would make this call succeed. Your client’s only job is to keep it
machine-readable all the way up, instead of flattening it into an exception at the first layer and
making every consumer reconstruct what the server already said precisely.
That settles the client’s obligation, and it is only half the problem. A value the client returns
is a value something above it has to deal with, and the obvious next move — surface the union to
whoever called submit() — is a mistake with a long tail. Step-up is a cross-cutting concern.
Handing it to call sites means every one of them branches on it, and the day you add device approval
you are editing all of them, or worse, finding out which ones quietly fell through.
The interaction host
The caller must not have to learn that a second factor happened. Not a branch on a union, not a
control-flow handler, not a prop threaded down. Application code calls submit() and awaits a
result, and by default the interruption is resolved underneath it. Must not have to is the
operative phrase: a caller that wants to take part has ways to, and they are the next section.
What makes that work is a host: one component, mounted once in application context, that knows how to satisfy each kind of challenge. The hook hands the challenge to it and — this is the mechanical core — the call’s promise stays pending throughout. It never resolves to a challenge. It resolves to the result, or it rejects. The interaction happens in between, and the only thing the caller can observe about it is a status on the hook.
This is the same inversion an HTTP client already makes for token refresh. Nobody writes
if (result.kind === 'token_expired') refresh() at every call site; the client does it, and the
caller sees a slightly slower call. Step-up is token refresh with a person in the loop, and it
earns the same treatment — as do consent screens, device approvals, and re-auth on a sensitive
field. These are the frontend’s cross-cutting concerns, and they belong with the other things you
mount once and stop thinking about: the toast host, the error boundary, the router.
Where the host sits in the three packages. The contract belongs to the headless layer — the challenge types, the context, and the progress states a hook is allowed to report. The default host, the one that actually renders a modal, ships in the components package, so an app can replace it without touching either layer beneath. And because it is context rather than a singleton, nesting gives scoped override for free: a settings pane that wants re-auth inline rather than in a global modal mounts its own host, and only the calls inside it change behaviour.
A challenge with no host has to fail loudly. Reject quickly, naming what was needed, rather than leaving a promise pending forever — a hang here is indistinguishable from a slow network. That case is real in tests and in server rendering. It is not the script-and-agent case (ZFN-38Field Note · currentZFN-38 — Agents are principals: delegate, never impersonateAn agent acting with a copied user credential is impersonation — untraceable by design. Give agents their own identities and keys; let them act for a human only through explicit, scoped, time-bounded, revocable delegation; and record both actor and principal on every action.Why it's cited here: The caller that kills the callback design. A script or an agent has no modal to open, and an SDK whose only step-up path is UI cannot serve one.Open ZFN-38 →): those callers use the base client directly and get the returned value, never a hook, which is precisely why the client’s shape had to stay honest.
Resumption is the hard part, and a pending promise is only its in-tab version. Inside one page
the host resumes the call that raised the challenge and the await completes. But a second factor
can take the person out of your tab entirely — to an authenticator, an email client, a bank app —
and a page reload destroys every pending promise there was. So the flow cannot live in the promise.
It lives in the host and the client’s own state: the challenge, its resumption token and its
progress are durable, a remounted host picks the flow back up, and the UI re-derives what to show
from the status signal rather than from a continuation it no longer holds. A design that only
resumes inside the React tree that raised it works in the demo and fails on a phone.
What the caller contributes
Default-transparent is the floor, not the ceiling. A call that contributes nothing still works — the host shows a generic prompt, the flow completes, the dialog is merely worse. Everything below is opt-in on top of that, and none of it changes what the promise resolves to: intent is an input at call time, status is a side channel, and the call still resolves to the result or rejects.
Status is observable. A caller can subscribe to what is happening — refreshing credentials, awaiting a second factor, waiting on the authenticator. This matters more than it sounds. A silent eight-second pause while somebody hunts for their phone is indistinguishable from a hang, and a button that can say check your phone is the difference between a considered interface and an apparently broken one. Place it like everything else: the base client emits progress as events, because events are data and a headless client can emit them; the hook surfaces the status of its own call; the app can subscribe globally for a top-level indicator.
Intent is contributable. The host owns the mechanism — which authenticator API, the retry, the modal chrome, the accessibility. The call site owns the meaning, and it is the only layer that has it. A dialog reading Confirm with your passkey and nothing else is phishing-shaped by construction: it asks somebody to authorise something they cannot see. When the server wants a second factor before it will sign over a payload, the call that knows what that payload is should be able to say so, and the host should render it.
The line that keeps this layered is that content and status cross it, and mechanism and control
flow do not. Contributions are declared as part of the call rather than registered as a handler
beside it, which is the entire distinction in one line: transfers.create(body, { intent }) stays
layered, transfers.create(body, { onNeedsMFA }) does not. The first passes data the host will
render. The second hands a call site a job that belongs to the host, and it is what comes back to
bite the day you add a second challenge kind.
Bind what you display, or it is only decoration. A contributed description is a string, and a string can be wrong — stale, mistranslated, or swapped by an attacker who reached the page. Secure Payment ConfirmationSecure Payment Confirmation (W3C Candidate Recommendation Draft, 2 July 2026)A Web API for authentication during a payment. The caller supplies the payee, amount and instrument; the browser guarantees the user is shown them and folds them into `CollectedClientPaymentData`, which replaces the usual client data and cannot be tampered with from JavaScript — so the WebAuthn assertion commits to what was actually on screen, and the verifying server can check that it matches the transaction it is about to execute.w3.org ↗ is the worked example of doing this properly: the caller supplies the payee, the amount and the instrument, the browser guarantees the person is shown them, and it folds them into the signed client data, so the assertion commits to what was on screen. The server verifying that assertion can then check that what the person saw matches what it is about to do. That turns a courtesy into a property somebody can verify.
There is no general mechanism for this outside payments, and the history explains why. WebAuthn
Level 1 carried a txAuthSimple extension for exactly this purpose — a prompt string for the
authenticator to display — and Level 2 dropped it, because browsers never implemented it and almost
no authenticator has a trusted display to render it on. SPC works precisely by moving both the
display and the binding into the browser. For anything that is not a payment, the job is left with
you: your host renders the contributed context, and you bind it into whatever the server verifies.
Do the first without the second and you have built a confirmation dialog capable of lying.
Session and credentials
- One session object, at the base layer. Refresh deduplication happens there. A React context reads the session; it does not own it.
- Bind the credential to a key the client holds where you can (ZFN-6Field Note · currentZFN-6 — Bind tokens to a key: sender-constrained tokens (DPoP)A bearer token grants access to whoever holds it — steal it, replay it. Bind the token to a holder key (DPoP, RFC 9449) so using it requires proving possession of a private key the token names. A stolen token alone becomes useless.Why it's cited here: A base-layer decision that the layers above never see — which is the property good layering is bought for.Open ZFN-6 →). That is a base-layer decision, invisible to everything above it — which is exactly the property the layering was bought for.
- Sign-out, expiry and revocation are events the client publishes and the hooks subscribe to. Anything else and half the UI keeps confidently rendering a signed-in state for a session that ended.
Consequences
Easier:
- Someone can adopt your protocol and none of your UI. Treat that as the product working, not as a defection.
- Testing splits along the seams: contract tests for the client with no renderer at all, a test renderer plus a fake client for the hooks, and whatever visual testing you do for the components.
- Replacing the component layer — a redesign, a different framework, a native shell — stops being a rewrite (ZFN-23Field Note · currentZFN-23 — Rewriting an implementation is fine — refactoring isn't always the answerRefactoring isn't always right. When the structure is wrong at the root, it's fine — often better — to rewrite an implementation from scratch. Clean interfaces and data models make the implementation disposable: stable contract, swappable internals. LLMs make it cheaper still.Why it's cited here: What the bottom two packages buy. A component layer you can throw away is only safe if the contract under it holds.Open ZFN-23 →). The bottom two packages are the asset.
- Non-browser callers work by construction, because they were never retrofitted.
- A slow step-up stops looking like a hang, because the status channel exists whether or not a given call chooses to render it.
- Adding a challenge kind is a change in one host, not a migration across every call site. Hand a discriminated union to callers instead and each new variant becomes a breaking change — or a silently unhandled case, which is worse.
Harder:
- Three packages to version, publish and keep in step, and somebody will end up running a mismatched set of them.
- The headless layer is hard to design and expensive to change once shipped. That is the point of putting it in the middle, but it does mean the difficult work arrives early.
- Every convenience arrives as pressure to break a layer — “just let the client take a router so it can redirect.” The answer is no, followed immediately by the seam that gets them what they wanted.
- Contributed display text becomes a security surface. If what the dialog shows is not bound into what the server verifies, you have two representations of one action, free to drift, and the cheerful failure mode is a dialog that authorises something other than what it describes.
- The host becomes a dependency of anything interruptible, so a test rendering such a hook has to mount one, and a challenge kind nobody registered has to reject loudly instead of hanging. Both failures are quiet by default and worth a deliberate test each.
- For a small app that will never expose an SDK, this is more indirection than the problem deserves. The layering pays off when there is a second consumer; be honest about whether there will be one.
New obligations:
- A dependency rule enforcing the direction of imports, running in CI. One-directional invariants survive only while something checks them (ZFN-22Field Note · currentZFN-22 — Quarantine bad architecture behind an interface, then replace itWhen a subsystem is complex and badly architected, quarantine it at its seam: write a clean adapter interface over the mess so the rest of the system depends on the contract, then build a better implementation behind it and expose the new interface directly.Why it's cited here: The general form of the rule. Each layer here is a seam, and the test is whether a change has one place to live.Open ZFN-22 →).
- Interruptions become a design surface with an owner. Every new one — another factor, a consent screen, a device approval, a re-auth on a sensitive field — has to be expressible as a challenge value and registrable with the host, so adding one reaches neither the call sites nor the client. The first one that cannot be will arrive as a callback and take the layering with it.
References
- RFC 9470RFC 9470 — OAuth 2.0 Step Up Authentication Challenge ProtocolStandards Track, September 2023. Lets a resource server answer a request with 401 and `WWW-Authenticate: Bearer error="insufficient_user_authentication"`, carrying `acr_values` for the assurance level it wants and `max_age` for how recent the authentication must be — a machine-readable statement of what would make the call succeed. The token and introspection response carry `acr` and `auth_time` so the client can tell whether it has been satisfied.rfc-editor.org ↗ — step-up authentication as a machine-readable challenge rather than a failure. The precedent for returning an interruption as data, from the layer that had the same problem first.
- Abramov, Presentational and Container ComponentsDan Abramov — Presentational and Container Components (2015)The post that named the split between components that fetch and hold state and components that render. Abramov added an update in 2019 withdrawing the recommendation: he no longer suggests dividing components this way, because hooks separate stateful logic without the arbitrary wrapper. He was careful that what he retracted was the class-era pattern, not separation of concerns.medium.com ↗ — the ancestor idea, and the author’s own 2019 retraction of it. Worth reading for what he withdrew: a split by component shape, not a split by rate of change.
- Secure Payment ConfirmationSecure Payment Confirmation (W3C Candidate Recommendation Draft, 2 July 2026)A Web API for authentication during a payment. The caller supplies the payee, amount and instrument; the browser guarantees the user is shown them and folds them into `CollectedClientPaymentData`, which replaces the usual client data and cannot be tampered with from JavaScript — so the WebAuthn assertion commits to what was actually on screen, and the verifying server can check that it matches the transaction it is about to execute.w3.org ↗ — caller-supplied transaction detail, displayed by the browser and bound into the assertion. The model for letting a call site contribute to a dialog without the contribution being merely cosmetic.
useSyncExternalStoreReact — useSyncExternalStoreThe hook for subscribing a component to a store that lives outside React. Takes `subscribe`, `getSnapshot`, and an optional `getServerSnapshot` for SSR and hydration. Its reason to exist is tearing: under concurrent rendering, components reading an external store by other means can render against different versions of it within a single commit.react.dev ↗ — the supported seam between a store React does not own and a component tree that renders from it.- ZFN-66Field Note · currentZFN-66 — Agonize over the interface, not the choice behind itRe-implementing a decision now costs hours. Changing an interface costs everyone standing on it. Spend deliberation at the boundary; hold the choice behind it loosely — on one condition: you know a decision was made, and where it lives. Unnoticed ones are the expensive kind.Why it's cited here: The reason the middle layer is where deliberation goes. The hook signature is the boundary with people standing on it; the client behind it and the components above it are both cheap to redo.Open ZFN-66 → — why the middle boundary gets the deliberation and the two layers around it do not.
- ZFN-14Field Note · currentZFN-14 — Define every API with a schema, and generate the clientsDefine every API with a machine-readable schema (OpenAPI, Protobuf, GraphQL) as the source of truth, and generate clients and server stubs from it — never hand-roll request-building and JSON parsing. Hand-written clients drift and break silently; check schema compatibility in CI.Why it's cited here: Why the base client is generated rather than written. A protocol hand-typed into a client is a second, divergent definition of the protocol.Open ZFN-14 → — generate the base client; a hand-written one is a second definition of the protocol.
- ZFN-58Field Note · currentZFN-58 — Errors are part of the contractError paths are the half of your API clients depend on most, and usually the half nobody designed. Enumerate error codes in the schema like any other type: stable code, retryable-or-not, whose fault, structured params. Machines branch on codes — anyone parsing prose is broken.Why it's cited here: The argument this note applies to step-up. A challenge is the protocol working, so modelling it as an exception is the same mistake as an untyped error.Open ZFN-58 → — a challenge is not an error, and the taxonomy is what tells a caller which it has.
- ZFN-38Field Note · currentZFN-38 — Agents are principals: delegate, never impersonateAn agent acting with a copied user credential is impersonation — untraceable by design. Give agents their own identities and keys; let them act for a human only through explicit, scoped, time-bounded, revocable delegation; and record both actor and principal on every action.Why it's cited here: The caller that kills the callback design. A script or an agent has no modal to open, and an SDK whose only step-up path is UI cannot serve one.Open ZFN-38 → — the non-interactive caller that a UI callback locks out.
- ZFN-25Field Note · currentZFN-25 — Track the version a client has seen for read-your-writesFor read-your-writes across backends, track the latest version a client has seen — a token or vector clock. Return it on write; reads then go to a backend at least that fresh. Hold it client-side (a token they present) or server-side (a gateway tracks the session and routes).Why it's cited here: What the hook layer should wait on after a mutation. A version token beats refetch-and-hope for knowing when a write is visible.Open ZFN-25 → / ZFN-61Field Note · currentZFN-61 — Propagate the deadlineEvery request has a deadline whether you set one or not — the caller's patience. Make it explicit at the edge, carry it as remaining budget on every hop, check it before expensive steps, and cancel downstream when it dies. Work past the deadline is the fuel of cascading collapse.Why it's cited here: Cancellation as a first-class part of the call, which is what an unmounting component needs from the layer beneath it.Open ZFN-61 → — what the hook layer should wait on, and what it should cancel.
- ZFN-31Field Note · currentZFN-31 — Own your components — when you deeply understand the domainOwning your own components rather than generic off-the-shelf services is often the better path as you grow: own what's core, lean on small vetted libraries for the hard parts. LLMs make it attainable at smaller scale — but only when you truly understand the domain, or it hurts.Why it's cited here: Why the components have to be replaceable. The people who replace them are the ones who need you most.Open ZFN-31 → — the component layer is the replaceable one, on purpose.
Changelog
- 2026-08-30: First published as a Field Note.
- 2026-08-30: Reworked the interruption argument. The first version had the challenge surface to the caller as a discriminated union to branch on, which pushes a cross-cutting concern straight back into every call site. Replaced with an interaction host in application context: the hook delegates to it, the call’s promise stays pending, and the caller sees only a progress signal. Added where the host sits across the three packages, and separated in-tab resumption from resumption across a page reload.
- 2026-08-30: Softened “the caller never learns” to “never has to”. Transparent by default was right; mandatory ignorance was not. Added what a caller may opt into — subscribing to status, and contributing the intent a dialog renders — with the line drawn at content and status crossing the boundary while mechanism and control flow stay behind it. Added the requirement to bind what is displayed, with Secure Payment Confirmation as the worked example.