docs: propose scaffold posture defaults and copy-as-branding-revision ADRs - #714
Conversation
ADR 043: the supported floors are Next.js 15+ and React 18+. The gate lives in the framework detectors, so setup refuses a below-floor app before any mutation and doctor's framework check fails on one — both with an explicit E_UNSUPPORTED_PROJECT_SHAPE naming the floor and an upgrade hint, never a silent narrowing. Unparseable version specs pass (no provable violation). @zitadel/sdk-next's peer range follows the floor (next >=15).
… ADRs ADR 044 (Proposed): scaffolded pages derive their embedding surface from the recorded fresh-vs-pre-existing hinge — fresh keeps variant="page", pre-existing apps get variant="widget" in a layout-neutral wrapper. ADR 045 (Proposed): audience copy overlays move from bundle-shipped presets wired per SDK into branding revisions resolved with the flow response; implementation stays fenced behind the templates-track milestone.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
|
There was a problem hiding this comment.
Pull request overview
This PR adds three new ADRs (043–045) and updates the ADR index, while also including (currently, in the shown diff) the framework version-floor enforcement work for the CLI detectors and the corresponding release/test updates.
Changes:
- Add ADR 044 (scaffold embedding posture defaults) and ADR 045 (copy overlays as branding revisions), plus ADR 043 (framework version floors) and register them in the ADR index.
- Enforce Next.js 15+ and React 18+ floors in CLI framework detectors, with updated unit/integration coverage and CLI contract docs.
- Update
@zitadel/sdk-nextpeer dependency floor for Next.js and add a changeset describing the shipped behavior.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/sdk-next/package.json | Raises next peer dependency floor to >=15. |
| docs/adrs/README.md | Adds ADR index entries for 043–045. |
| docs/adrs/043-framework-version-floors.md | New accepted ADR documenting Next/React floors and enforcement approach. |
| docs/adrs/044-scaffold-embedding-posture-defaults.md | New proposed ADR for page vs widget posture defaults based on scaffolded vs pre-existing app. |
| docs/adrs/045-copy-overlays-as-branding-revisions.md | New proposed ADR to move business copy overlays into branding revisions (templates-track fenced). |
| apps/cli/tests/unit/lib/orca/detectors/react.test.ts | Adds React detector floor/versionMajor coverage. |
| apps/cli/tests/unit/lib/orca/detectors/next.test.ts | Updates Next detector expectations and adds floor/unparseable tests. |
| apps/cli/tests/unit/commands/doctor/checks.test.ts | Adds doctor framework-check coverage for downgraded Next below floor. |
| apps/cli/tests/integration/setup-next.test.ts | Adds setup refusal integration test for Next below floor and parameterizes Next version. |
| apps/cli/src/lib/orca/detectors/react.ts | Adds React 18+ floor enforcement and optional versionMajor capture. |
| apps/cli/src/lib/orca/detectors/next.ts | Adds Next 15+ floor enforcement and preserves optional versionMajor capture. |
| apps/cli/SKILLS.md | Documents supported floors and the shared E_UNSUPPORTED_PROJECT_SHAPE behavior. |
| .changeset/framework-version-floors.md | Adds release intent for CLI + sdk-next floor enforcement and peer range update. |
…ugh doctor The floor gate now judges the declared spec with real range semantics (semver.validRange + intersects against >=floor.0.0-0): "<15" rejects, "file:../next-14-patched" and dist-tags pass — a first-digits parse inverted both. Doctor stops laundering the typed error: CheckOutcome carries the ZitadelError code and hint, the envelope prefers a coded failure over generic E_VALIDATION, and the advice ladder surfaces the check's own remedy instead of recommending a --fix that cannot repair an unsupported version. Changeset loses the internal ADR reference; the Next floor comment loses the unrelated React 19 clause.
…he release boundary Review round: ADR 044 now applies only to integrations that add routes without owning the app shell (Next, Nuxt) — the SPA patchers write the app root, so a pre-existing shell is conflicted-with or replaced, never inherited; non-destructive insertion moves to open questions. The posture is recorded in the scaffold manifest, and absence of a record (all legacy scaffolds) restores page, so widget restoration needs positive evidence. ADR 045 no longer implies revision creation changes runtime wording: under ADR 035 a copy-bearing revision is inert until a configuration release containing it is deployed, with ADR 040's latest-revision resolution named as the acknowledged interim.
|
Codex round (relayed) addressed on 48d47b0 — all three findings amended into the proposals. 1. [P1] ADR 044 scoped to route-based integrations. Correct — the SPA patchers write the app root, so a pre-existing shell is conflicted-with or 2. [P1] ADR 045 aligned with ADR 035's release boundary. The Decision now states that effectiveness follows the release boundary, not revision creation: under ADR 035 a copy-bearing branding revision is an inert draft until a configuration release containing it is deployed to the environment, and the flow response serves copy from the environment's current release — never undeployed drafts. ADR 040's latest-revision resolution is named as the acknowledged interim, with copy edits reaching runtime through whichever lifecycle is in force. The "runtime-changeable" consequence is rephrased to "no app rebuild or redeploy — wording ships as configuration changes with release/deployment semantics", which also gains the per-environment wording capability the bundle approach cannot express. 3. [P2] Legacy posture ambiguity resolved by recording + a positive-evidence rule. The manifest gains an explicit The four Copilot threads are replied-to and resolved: the two version-spec cases and the changeset wording were fixed on #713's bb22a1c and carried here by the stack-forward merge; the docs-only/diff mismatch is the deliberate stacking (ADR index contiguity) documented in the PR notes, collapsing on #713's merge. Branch is reconciled with main (the BEHIND flag). 🤖 Addressed by Claude Code |
…-and-copy-adrs # Conflicts: # docs/adrs/README.md
…#717) ## Summary The 2026-08-02 Wicklore rerun (finding N3) showed that after embedding the scaffolded login/session widgets in a real app, the app's own chrome has no supported way to know session state — the header keeps rendering "Sign in"/"Join" beside a live session. The widgets read `GET /sessions/me` internally, but nothing documented how the host page gets the same answer. Root cause is structural on Next: the scaffolded request boundary's `matcher` covers only `/__nextgen/:path*` and `/profile/:path*`, so on any other page the middleware never runs, no auth header is tunnelled, and server-side `auth()` reports signed-out. Nuxt doesn't share the gap (its scaffolded auth plugin seeds `useAuth()` on every render) — its gap was purely documentation. This PR ships the smallest honest surface: - **`@zitadel/sdk-next/session`** — new dependency-light entry exporting `getSession()`: a client-side fetch of the same-origin `{proxyPath}/sessions/me`, the exact read `<zitadel-session>` performs. Works on any page (only the proxy path must be matched, which the scaffold always does), needs no `configureZitadel()`, and returns the client-safe `ClientAuthResult` — no token. `401`/`404` and anonymous sessions map to signed-out; other failures throw rather than silently rendering signed-out; calling it server-side throws with a pointer to `auth()`. It's a dedicated subpath because the root barrel pulls `next/headers` and `./client` pulls Lit — both unsafe to import from an SSR'd header component. - **`ClientSession` / `ClientAuthState` / `ClientAuthResult`** lifted from sdk-nuxt into `@zitadel/sdk-core` as the single source; sdk-nuxt re-exports unchanged, so `useAuth()` (Nuxt) and `getSession()` (Next) return the identical shape. - **Scaffold guidance**: one framework-branched paragraph in the generated `AGENTS.md` managed section — Next names `getSession()` (plus `auth()` with its matcher precondition), Nuxt names `useAuth()`, SPA frameworks get the raw `/__nextgen/sessions/me` read (no claimed helper that doesn't exist there). Matching one-line comments emitted in the Next and Nuxt profile-page templates, plus a README section with a conditional-header example. Deliberately **not** in scope: no state-management layer, no new widget events (`zitadel-signout` / `zitadel-flow-complete` already exist, and the scaffolded posture navigates on both transitions, so read-on-load suffices), no `NextgenProvider`-based pattern (see Notes). ## Validation - `sdk-next:test` 103/103 (8 new `getSession()` tests: identity mapping, null-attribute handling, anonymous session, 401/404 vs throw semantics, proxy-path resolution order, server-side guard) - `cli:test` 942/942 (new guidance test pinning the per-framework branching, incl. that non-Next frameworks never reference `@zitadel/sdk-next/session`) - `sdk-nuxt:test`, plus build / typecheck / lint green across sdk-core, sdk-next, sdk-nuxt, cli; `demo-nuxt:typecheck` green (consumes the re-exported types through the built dist) - Built `dist/session.js` smoke-tested by direct import (function resolves; server-side guard fires) - `changeset status` verified - Known-good caveat: `demo-next:typecheck` fails in this fresh worktree, stash-verified identical **without** these changes (pre-existing JSX-augmentation issue) ## Release notes / changeset `.changeset/session-state-for-app-chrome.md` — `@zitadel/sdk-next` minor (new `./session` entry), `@zitadel/sdk-core` minor (new exported client-safe types), `@zitadel/cli` minor (guidance + emitted comments), `@zitadel/sdk-nuxt` patch (type re-export, no behavior change). ## Notes - ADR 044/045 (#714) checked before building: 044 (page/widget posture) is adjacent but doesn't cover session-state consumption — the new guidance paragraph and the profile-page comment are posture-neutral and survive 044's template branching; 045 is unrelated. - Found in passing, deliberately excluded, and being hardened in a separate session/PR: `auth()` on matcher-uncovered routes trusts a client-forgeable unverified `x-nextgen-auth-token` header, and the README's `NextgenProvider` pattern serializes the raw session token into the RSC payload (sdk-nuxt strips it for exactly this reason). Both are behavior changes to existing surface with a design decision in the `auth()` fix; this PR only narrows documented `auth()` usage to the safe (matcher-covered) region. The follow-up builds on the `ClientAuthResult` types this PR moves to sdk-core.
## Why
Flows that deliver a secret out of band — email verification, email-OTP
login, password recovery (ADR 038) — cannot be tested end-to-end, and
won't be when they arrive, unless tests can observe what the server
sent. The server has **no email delivery subsystem at all yet**, which
makes this the cheapest possible moment to fix the contract. And the
platform overview already *narrates* the capability this ADR specifies:
the scratch dashboard's dev inbox and the "Dev Inbox / Bring Your Own /
Managed" delivery picker. This ADR is the server-side contract behind
that story.
## What changed
**ADR 050 (Proposed): The Dev Inbox — Captured Outbound Messages.**
Rewritten from the initial "capture transport" draft after two review
inputs — a competitor scan (Supabase, Resend, Clerk, Firebase emulator,
Stytch, Better Auth) and a codex review — into a product-capability
framing:
- Composition/delivery split with per-environment **delivery modes**;
`capture` stores messages in the project/environment-scoped **Dev
Inbox** (ADR 035 scoping, ADR 036 operator plane).
- Messages are a **versioned record contract** (`schema_version`)
carrying typed, purpose-discriminated **`artifacts`** (code/link —
populated by composition from the flow engine's own state, never parsed
from templates; `variables` stays a non-API record of render inputs)
plus `rendered` forms for humans. Two timestamps with distinct meanings:
`expires_at` = artifact/challenge validity (guaranteed shared per
record), `retained_until` = store retention.
- **One cursor-paginated API, three consumers**: inbox UI, `zitadel
dev-inbox` JSON commands, and `@zitadel/testing` (`cursor()` +
`waitForMessage({after, to, purpose, artifact})` — the artifact-key
predicate makes `waitForCode()` wait for a *code-bearing* record, since
a purpose may produce link-only messages; keys, never values, appear in
list metadata) — no test-level `clear()`, which would be cross-worker
destructive under the kit's shared-instance parallelism. Waits are
bounded and cancellable (`timeoutMs`, `AbortSignal`, typed timeout error
echoing the resolved filters), and every consumer call binds an
**explicit environment** (`InstanceHandle.environmentId`; CLI `--env`
with deterministic resolution, echoed in the envelope).
- **Zero-config at the front doors, explicit in the binary**: `zitadel
start` / `startLocalZitadel()` enable it, `connectZitadel()` discovers
it; no tee mode.
- **Production-class environments require a real provider, enforced by a
`provider_required` environment policy** (ADR 035 defers env-classes, so
the ADR defines the enforceable bit instead of a classification) —
enforced at **deploy, on every provider/policy mutation, at startup, and
at send time** (a send without a working provider fails loudly, never
falls back to capture); one deliberate divergence from the overview's
current "Dev Inbox (default)" for claimed production, flagged in the
ADR.
- **Browser inbox surfaces never hold the operator credential — and must
earn their session** (ADR 036): BFF with a scoped HTTP-only session
minted only through an authenticated exchange — human auth on claimed
surfaces, or a one-time handoff minted by the explicit `zitadel
dev-inbox open`, with the transfer specified: fragment-carried token
(never in access logs), POST exchange, `history.replaceState`,
`Referrer-Policy: no-referrer`, log redaction. `zitadel start` never
emits a bearer-bearing URL — it reports `available` vs `configured`
capability state and points at `open` only via a human-facing
`next_actions` hint, never `next_commands` (which agents execute by
contract, and which would also fail pre-`setup` when no secret exists).
The scratch dashboard's anonymous first-visit cookie is called out as
insufficient for inbox content — a second overview amendment.
- **List responses are metadata-only** — masking `variables` alone would
be theater while `rendered.*` embeds the same secret; the full message
is a separately authorized read. **Any durable backend encrypts message
content at rest** (ADR 029); only the ephemeral in-memory store is
exempt.
- **The kit cursor is a high-water mark, not an ADR 027 page token**:
defined on empty inboxes, ordered by the store's monotonic
per-environment sequence, purge-stable, carrying a **store epoch** (a
restarted in-memory store fails old cursors fast instead of hanging
waits), with distinct stale-token errors. The **rendered preview is
sandboxed** (iframe, no scripts, restrictive CSP) per the flow-engine
template-security guidance.
- Alternatives rejected with reasoning: external SMTP sink (Supabase's
own layering as evidence), log capture, **fixed test codes on the real
server** (Clerk-style — that experience belongs in `@zitadel/api-mock`),
**admin-mint** (hash-only storage + flow-binding), kit-embedded SMTP
listener.
- Staged build order: contracts → store/API → kit → CLI → UI → real
providers.
Also in this branch's history: renumbered as `main` claimed numbers in
flight — 042 → 043 → 044 → 046 → 047 → 048 → **050** (042
scaffolded-file ownership, 043 framework version floors #713, 044+045
scaffold posture and copy overlays #714, 046 claim lifecycle v2 #627,
047 dialect-owned identifier generation #663, 048+049 wide events #459)
— with `main` merged in at each collision; the file is now
`050-dev-inbox.md`.
## Validation
Docs-only.
## Review notes
Status is **Proposed** — a conversation starter. The first review round
(livio) recorded a lean on every open question, now folded into the ADR:
composition via a typed flow intent + async notification boundary
(v4-shaped enqueue → compose → capture|deliver, wide-events/River as the
bus); durable store only on multi-replica / shared scratch inbox /
unread-survives-restart (ADR 029 bar applies equally to queue payloads);
default-deny support access until #333; optional explicit `consumed_at`
over auto-consume; one operator port with an optional path-prefix
kill-switch. The same round added a normative purpose × artifact-key
table, the static email-dependence detection signal for the
`provider_required` gates, and an "On acceptance" checklist carrying
both platform-overview amendments (claimed-production delivery default;
scratch-inbox session acquisition). The leans harden into decisions when
the ADR moves to Accepted.
A later `main` merge brought #722's ADR 036 amendment (credential
exposure contracts), which governs exactly the fragment-carried handoff
token this ADR specifies — the BFF section now carries an explicit
conformance passage instead of a silent divergence: the exchange behaves
as a credential-establishing operation (browser-attested `Origin`
enforced, so an exfiltrated token can't be exchanged from a foreign
context), the minted session is inbox-scoped and can never become a
login session, and the endpoint adopts ADR 036's PKCE-style proof
binding for URL-transiting handoffs once that machinery ships.
The #459 merge (wide events, ADRs 048/049) touches the composition lean,
which named "wide-events/River" as the natural bus: ADR 048 defines wide
events as an *audit* primitive (deny-by-default PII, export-only), so
the lean now cross-references it and folds "can the audit stream double
as the dispatch bus, or does the boundary ride a job queue with its own
payloads" into the already-open event-shape question. ADR 049's events
API stays on plain ADR 027 keyset cursors, so it does not collide with
this ADR's high-water-mark cursor contract.
Summary
The two deliberately-parked P2 items from the Wicklore remediation plan, written as Proposed ADRs for team alignment — per the plan's rule that these get no implementation before acceptance (built-ahead-of-alignment is how PR #56/#159 rotted):
scaffolded_framework, ADR 042): fresh scaffolds keepvariant="page", pre-existing apps getvariant="widget"in a layout-neutral wrapper so the card stops fighting the host app's shell. Posture stays a page edit (no config knob), doctor restores it from the manifest. Open questions flagged: an explicit--surfaceoverride, and per-page posture recording.businessLocales) is branding-shaped data living in code — per-app-build, multiplied across eight SDK/template integration points. Proposal: branding revisions carry per-language copy resolved with the flow response;--use-case businessseeds a revision instead of wiring template props; wording edits follow eject→edit→apply and become runtime-effective. Fenced behind the templates-track milestone — direction alignment only; the in-flight per-SDK parity wiring stays the interim mechanism and is explicitly named as transitional.Validation
node scripts/check-adrs-index.mjs— 45 records ok.Notes
Stacked on #713 (the index check enforces contiguous ADR numbers, so this branch carries 043) — the diff collapses to the two proposals once #713 merges.