Skip to content

Merge Sunrise 0.15.0 - #133

Merged
JohnD-EE merged 140 commits into
mainfrom
chore/merge-upstream-sunrise-v0.15.0
Oct 28, 2026
Merged

JohnD-EE merged 140 commits into
mainfrom
chore/merge-upstream-sunrise-v0.15.0

Conversation

@JohnD-EE

Copy link
Copy Markdown
Contributor

Brings Resparkable up to Sunrise 0.15.0: owner isolation, account deletion that is recorded first and finished in the background, the org-admin console and vendor support access. TENANCY_MODE stays single, so the org-admin console, per-org quotas and support access arrive switched off.

What the fork had to change

  1. The merge. 135 upstream commits, 34 conflicts, resolved by taking upstream's change and keeping the fork's additions on top. Upstream took Sunrise asks chore(deps): Bump openai from 6.49.0 to 7.4.0 #45 and chore(ci): Bump the github-actions group across 1 directory with 2 updates #52, so the fork's local copies give way to upstream's. Lockfile seeded from v0.15.0's and reconciled; no version floated.
  2. Platform jobs. Upstream's new pendingErasures job runs before the fork's transferJobs and transferArchiveExpiry. The fork's erasure hook and the transfer-archive cleanup in eraseUser() are both safe to run again on a retried attempt.
  3. Owner classification (§115). Sunrise's guard now audits fork schema files. TransferJob is personal (not enforced); ResparkableGroupMember and ResparkableGroupAuditEntry are not personal. Registered from the new lib/app/owner-classification.ts seam.
  4. Capability seed parity. The 24 capabilities are paired with their classes in lib/app/capability-seed-pairs.ts.
  5. Account transfer. User.signupOrgId and the five erasure columns are regenerated, never imported, so a bundle cannot move the importing account into another org or schedule its deletion. OrgSupportGrant is excluded.
  6. Erasure hook registration. The hook now registers from lib/app/erasure.ts instead of initResparkable(). Core runs that seam itself before it first reads the hook registry, so the hook is in place in any process that erases somebody, not only one that booted the app. A script calling eraseUser() directly used to skip it silently, leaving unaccepted invites and stored document originals behind. Core's erasure tests assume the seam ships empty, so they mock it empty under FORK notes; defaults.test.ts pins the real registration.
  7. Docs. Sunrise asks chore(deps-dev): Bump the development-minor-patch group with 9 updates #44 (sunrise#691), chore(deps): Bump openai from 6.49.0 to 7.4.0 #45 and chore(ci): Bump the github-actions group across 1 directory with 2 updates #52 recorded as landed. install.md §2.13 rewritten: it still said the erasure hook was gone, and a host project now has to wire it. CHANGELOG entry for the merge.

Checks run locally

  • npm run test:changed: 1,786 files, 36,744 tests passed.
  • Type-check, ESLint and Prettier clean on the files changed in the last commit.
  • Not run in this session: full npm run validate, migrations against the dev database, db:seed, drift probes, /security-review and /code-review.

Before deploying to a real database

  • Seventeen migrations, then npm run db:seed. Eight take locks that scale with table size (the delivery tables, ai_cost_log, ai_admin_audit_log and user; upstream's 0.15.0 notes list them), so apply them off-peak.
  • Account deletion can now answer 202 pending or 409. Both delete routes call requestUserErasure().

Known limits

  • TransferJob stays in platform.prisma, a core file, with a FORK note in owner-classification-fork.test.ts. That is deliberate: ask Account import/export (transfer) #36 proposes the whole portability engine for core, and if it lands, that file is the model's home.

🤖 Generated with Claude Code

simonholmes and others added 30 commits October 11, 2026 21:39
…979)

* Record where each webhook and event-hook delivery was sent, and keep it after the sender is deleted (t-739)

A delivery row now carries its own destination: the URL reduced by
loggableUrl (or the email address) plus a keyed HMAC fingerprint of the
full destination, so the record holds no URL secret but an operator can
still confirm an exact URL. Retries follow the parent's current
destination and move the old one into previousDestinations rather than
overwriting it.

The parent FK on both delivery tables becomes nullable with ON DELETE
SET NULL, so deleting a subscription or hook (or erasing the admin who
created a subscription) no longer deletes its delivery history. Retries
of an orphaned delivery stop and say why; the DLQ routes show orphans to
every admin of the org; the DLQ table renders them without a link or a
retry. Existing rows are backfilled from their current parent by the
022-delivery-destinations seed unit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Prove delivery destinations against a real database, and say what the export carries (t-739)

scripts/smoke/delivery-destination.ts exercises what mocked tests cannot:
the parent FK's ON DELETE SET NULL keeping a delivery (and its recorded
destination) after its subscription or hook is deleted, or after the
subscription's creator is erased; the org export carrying those rows
without the URL secret; and retention still pruning them. Wired into the
CI smoke job. The org-export manifest's descriptions now say a delivery
carries where it was sent, reduced, with a keyed fingerprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document where a delivery went, and what changed for code that reads one (t-739)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test delivery destination recording, orphaned deliveries and the backfill (t-739)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer code review round 1 on delivery destinations (t-739)

- Erasure: eraseUser() redacts the erased person's own address (and its
  fingerprint) from the deliveries their subscriptions sent, which now
  survive them; previousDestinations entries too.
- Orphaned webhook deliveries are a new 'webhookDelivery' kind in
  UNATTRIBUTED_READ_KINDS, read through
  lib/orchestration/access/webhook-delivery-access.ts, so the policy's
  'unattributed' arm decides who sees them instead of a hard-coded
  every-admin rule; the DLQ routes compose it with AND.
- Orphaned event-hook deliveries get a surface: GET /hooks/deliveries
  (?orphaned=true), since the per-hook list 404s once the hook is gone.
- GET /webhooks/dlq stops returning the subscription url (it can carry a
  credential); the table shows the recorded destination only.
- A retry the tick abandons keeps its last real error and appends
  '(not retried: ...)' instead of replacing it.
- The delete audit entry names the reduced URL and never an email address.
- Both retry routes answer 409 for an orphaned delivery.
- The delete-subscription dialog and the erasure doc no longer say the
  delivery history is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the review-round-1 fixes on delivery destinations (t-739)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cover the delivery-subscription table's error paths it now has to clear the per-file gate on (t-739)

The two it.todo entries described catch blocks the component has since
gained; they are real tests now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer code review round 2 on delivery destinations (t-739)

- A URL destination is recorded as its origin only. loggableUrl is a
  log-line heuristic that keeps short or slug-shaped path tokens, so the
  stored column could carry a credential; the full URL now lives only in
  the keyed fingerprint.
- An email destination is stored trimmed and lower-cased, so subject
  access and erasure match it exactly. One matcher,
  webhookDeliveriesAddressedTo(), serves both: exact on the normalised
  address (never ILIKE, whose _ and % match strangers), JSON containment
  for previousDestinations, verified addresses only.
- Erasure redacts the address on every delivery sent to it, not only on
  those reached through the person's live subscriptions: deliveries
  already orphaned by an earlier delete, and other admins' subscriptions,
  were missed. Only rows naming the address are read.
- The subject-access export gains notificationsSentToYou: deliveries
  emailed to the subject, without their payloads. Before t-739 the address
  lived only on the (exported) subscription; now it can outlive it.
- DeliveryLike moves beside RecordedDestination; stale 'four times'
  comment and the DLQ tab copy corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the review-round-2 fixes on delivery destinations (t-739)

The subject-pinning guard in export-user.test.ts now accepts an OR whose
every branch pins the subject, and an exact JSON containment of their
address — the shape of the new notificationsSentToYou source — and pins
the negative cases (one unpinned branch, empty OR, someone else's
address) so the widening cannot read others' rows unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer code review round 3 on delivery destinations (t-739)

- The subject-access export no longer returns a delivery's destination or
  previousDestinations: after a re-pointed retry one row can name two
  inboxes, so either column could hand a subject someone else's address.
  It states the subject's own address (sentTo) instead.
- Erasure stops any of the subject's deliveries still retrying (the next
  attempt would email them again and write the address back), keeping each
  row's real error; redacts the rest in one updateMany; rewrites only the
  rare rows that name the address in their history. The redacted count is
  on the 'User erased' log line.
- 20261011120000_delivery_destination_index: a btree on destination and a
  GIN (jsonb_path_ops) on previousDestinations, so neither half of the
  address match scans the table inside the erasure transaction.
- GET /webhooks/deliveries reaches delivered and failed orphans, which
  neither the per-subscription list nor the DLQ can.
- Bulk replay leaves out rows whose subscription was deleted, and says so
  when that is every row on the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the review-round-3 fixes, and build export rows field by field (t-739)

notificationsSentToYou spread each selected row; a widened select would
then have been enough to put a row's own address columns in front of the
subject. The rows are built field by field, and the probe test fails if
they are spread again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say that bulk replay skips rows whose subscription was deleted (t-739)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Anchor the DLQ table test's link-name pattern, which CodeQL read as a URL check (t-739)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Give each org its own storage prefix and token claim, and make agent uploads private by default at multi (t-736)

At TENANCY_MODE=multi, upload_to_storage writes under orgs/{orgId}/ through
getStorageClient({ orgId }), which refuses any key outside that org on
upload, read, delete and signing. A binding choosing neither public nor a
TTL now uploads privately with a 24h signed URL. Local access tokens carry
an org claim, refused on mint for a key outside the org and checked again
by the read route. eraseOrg deletes the org's prefix after its transaction
commits, best-effort, reporting storageSwept. single is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say in the capability and recipe docs that uploads are private by default at multi (t-736)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test org storage prefixes, the token org claim, and private-by-default uploads at multi (t-736)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a binding keyPrefix under the reserved orgs/ root at single, before uploading (t-736)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the reserved orgs/ storage root (t-736)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer code review round 1 on org storage prefixes (t-736)

- S3 and Vercel Blob deletePrefix follow every listing page, and S3
  per-key DeleteObjects errors make the result false, so eraseOrg's
  storageSwept no longer claims a sweep that stopped at 1000 objects.
- eraseOrg's sweep builds the provider inside its try, from client.ts,
  so nothing can throw after the commit.
- A default binding at multi on a provider that cannot store privately
  (S3 without S3_OBJECTS_PRIVATE_BY_DEFAULT / S3_USE_ACL) gets a remedy
  naming the deployment setting or public: true, not settings it never had.
- keyPrefix refuses ., .. and empty segments, so a path-normalising
  spelling cannot slip past the reserved orgs/ root.
- Mint and the read route share tokenOrgMatchesKey.
- The erasure doc names the in-flight-upload window and says to suspend first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Note the S3 private-objects requirement at multi and the deletePrefix pagination fix (t-736)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the review-round-1 fixes on org storage prefixes (t-736)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer code review round 2 on org storage prefixes (t-736)

- The unbound getStorageClient() refuses every key under the reserved
  orgs/ root (any case); the read route and eraseOrg use the owning org's
  client. An org's files are reachable only through that org's client.
- A key under orgs/ that names no org (orgs//x, Orgs/x) matches no token
  claim, so nothing can mint for it or read it.
- orgKeyPrefix accepts only [A-Za-z0-9_-] ids, so no id resolves to the
  orgs/ root (orgs/./).
- Vercel Blob deletePrefix re-lists until empty instead of following a
  cursor that deletion may invalidate, bounded by a round limit.
- sweepErasedOrgStorage(orgId) re-runs the sweep for an erased org;
  the docs said a re-run was possible and nothing could do it.
- SignedUrlOptions docs: a provider minting local-style tokens must
  forward the org.
- One S3 remedy string; getStorageClient({ orgId }) without the no-op
  conditional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say in the storage tenancy doc that the unbound client refuses org keys (t-736)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the review-round-2 fixes on org storage prefixes (t-736)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer code review round 3 on org storage prefixes (t-736)

- isUnderOrgRoot also covers prefixes of the root (orgs, org, o), so the
  unbound client cannot prefix-delete every org's tree, and judges a full
  blob URL by its path.
- validateStorageKey rejects . and empty segments (trailing / allowed;
  URLs judged by their path): ./orgs/b/x resolved into org b's directory
  on local past every string check.
- orgOfKey names only ids an org prefix could hold, so mint, match and
  bind agree on what an org id is (no 500 from the read route).
- The read route refuses an org-claimed token whose org is erased or
  suspended (ORG_UNAVAILABLE).
- upload_to_storage: case-insensitive reserved-root check; org prefix and
  client binding inside error handling.
- Vercel Blob deletePrefix: no re-delete or recount of stale listings,
  short wait between them, bounded stale run.
- Docs: getStorageClient() returns a guarded wrapper, not the instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the review-round-3 fixes on org storage prefixes (t-736)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Let a fork add its own tables to the org export (t-737)

A fork registers its orgId models from lib/app/org-export.ts through
registerAppOrgSources(), each source carrying its own fetch. exportOrgData
reads them after core's, as the org, into a new app key with meta.app;
both absent when nothing is registered. A throwing init is remembered and
the export refuses. The org-manifest coverage test unions the registry in,
and now also requires every orgId model's Org relation to cascade or set
null on erasure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the org-export fork seam and its registry (t-737)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer code review round 1 on the org-export seam (t-737)

Refuse section names that are built-in object members, drop wrongly
typed fields instead of throwing, match core's description floor, accept
a composite Org foreign key, use Object.hasOwn in the smoke check, and
say plainly that SetNull is a retention choice the rule cannot judge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer code review round 2 on the org-export seam (t-737)

A rejected declaration now makes exportOrgData refuse, as a throwing init
does: the coverage guard cannot see a dropped source for a table without
an orgId column. Null entries are dropped with a log instead of throwing.
The shadow check is one helper the test exercises, the fork-seam tests run
the seam's own init first, and the smoke check that could not fail is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer code review round 3 on the org-export seam (t-737)

Refuse only when a rejected row leaves a model uncovered, not for a
rejection another declaration already answers. Test the checkout's own
seam declarations at build time. Export ownedBy for fork sources so a
NULL-org row reaches the install org's export at single. Carry exactly
model and reason into meta.excluded, label any model value without
throwing, and read onDelete from the code, not a comment, keeping the
unsafe policy when a model has two Org relations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Scope backup export and import to one org, and move the shared part only for the install org (t-738)

Both routes take ?orgId= and ?shared=, resolved by resolveBackupScope():
the target defaults to the active org, an unbound admin key must name one
at multi, and naming another org re-asks the policy about it. The shared
part (capabilities, knowledge tags, settings) moves only for the install
org or an unbound key; a customer org that asks is refused. Exporter and
importer run inside runAsOrg(target), so provider warnings and version
records are the target's. The import route drops writesSharedSettings
and is listed as enforcing the rule itself, so a customer org can restore
its own part. Cross-org workflow slugs take a free slug; tag grants fall
back to existing tags. The backup panel offers the shared part only where
it may move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test org-scoped backup: scope resolution, importer, exporter, routes, panel and a real-Postgres smoke (t-738)

The isolation smoke gains [19b]: A's own part, exported in A and imported
into B from inside A and from no org, changes only B's rows, leaves A's
agent, workflows and every shared row untouched, lands A's workflow under
a free slug, and skips a bundle's shared part when not asked for. The
t-751 shared-settings test drops its backup-import row: a customer org's
import is no longer refused at the guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer code review round 1 on org-scoped backup (t-738)

The shared part now follows the caller's org, as the ruling and t-751's
guard read it, not the org named in ?orgId=. A refusal is logged. An
import naming a suspended org is refused. A renamed workflow is marked
with metadata.importedFromSlug so a repeat import updates it; a new slug
avoids the bundle's own slugs and this import's creations, and the probe
is skipped at single. Audit entityId is the target org. The skip warning
is worded for the mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the review-round-1 fixes on org-scoped backup (t-738)

The smoke's repeat import now has to update B's one copy of A's workflow,
found by its importedFromSlug marker, rather than add another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer code review round 2 on org-scoped backup (t-738)

Plan renamed workflow slugs before the transaction, so no probe waits on a
second connection while it holds one; a slug lost to a race answers 409.
The marker lookup is ordered. The shared rule reuses canChangeSharedSettings.
Bundle metadata is parsed, not cast. The panel shows the API's message when
an export is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the review-round-2 fixes on org-scoped backup (t-738)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer code review round 3 on org-scoped backup (t-738)

Ask the policy before saying whether a named org exists or is suspended.
Plan renamed slugs in two queries, skipping slugs the loop skips. Look up
the renamed-copy marker only when another org holds the slug, and strip
it on export. Count a v1 bundle's category tags in the shared-skip report.
Name the org in the export filename. Reuse parseApiResponse in the panel
and show sharedSkipped there. Correct slug-availability's comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the review-round-3 fixes on org-scoped backup (t-738)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
checkBudget() runs inside the guard's runAsOrg, and the global total is an
unfiltered AiCostLog aggregate read in that scope. With org_isolation on it
sees only the acting org's rows, so the global cap acts as the same cap per
org. This test records the scope each aggregate runs in; the two multi
cases fail until the total is read in the system scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e again (t-788)

- Org.settings gains a 'quotas' slice holding monthlyBudgetUsd, with a
  typed read/load/write helper (readOrgQuotas / loadOrgQuotas /
  writeOrgQuotas) for forks' billing code. The read is per key and fails
  open to 'no org cap'; the global cap still applies.
- PATCH /admin/orgs/[id] writes it as settings.quotas. At multi only the
  install org (or an unbound admin API key) may, so a platform admin
  switched into a customer org cannot raise that org's cap.
- checkBudget() enforces the org cap beside the agent and global caps, on
  month-to-date spend filtered by orgId. No quotas set gives the same
  result as before.
- The global total is summed in the system scope through the confined
  cross-org count module, so it covers every org and detached rows. The
  agents list and the costs alert read the same getGlobalCapState().
- The streaming handler emits budget_exceeded / org_budget_exceeded /
  global_budget_exceeded, and the budget_exceeded webhook carries 'cap'.
- (orgId, createdAt) index on AiCostLog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Costs doc: the org cap, who may set it, fail-open reads, the per-agent lock
bound, and the three chat codes. Multi-tenancy and identity docs list the
quotas slice. The design doc's out-of-scope line now says per-tenant copies
of the settings singleton are out, and a per-org cap in Org.settings is in.
CHANGELOG and VERSIONING carry the helper, the quotas key, the new codes
and the global-cap scope fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… smoke (t-788)

Asked from org A, the global spend total counts org B's cost row; an org
cap below A's spend stops A's agent and B's own cap does not count A's
spend. Runs in CI's smoke-multi job; not run locally (no throwaway
Postgres with the role split here).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ions (t-783, t-786) (#989)

* Erase a person from delivery payloads and colleagues' email subscriptions (t-783, t-786)

Deliveries whose payload names the subject - their id, a conversation or
run of theirs, as the actor or an approver - are redacted on erasure and
exported on subject access, matched by JSON containment through a new GIN
index on each delivery table's payload. A colleague's email subscription to
the subject's verified address is cleared and deactivated, with an audit
entry. The subject export's notificationsSentToYou section becomes
webhookDeliveries; event-hook deliveries join as eventHookDeliveries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test and document erasure of delivery payloads and colleagues' subscriptions (t-783, t-786)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Admit the new raw-SQL and ownerless-read sites to their guards (t-783, t-786)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep org configuration out of a subject's delivery export (t-783)

An event about the subject's run can carry the org's own configuration: a
notification step's webhookUrl (often holding its credential), approval
links, the body an admin's template rendered. The export's activity view is
now an allowlist - the record keys plus the free text about the subject -
as the erasure side already was. Found by /security-review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address code-review round 1 on delivery erasure (t-783, t-786)

- actorUserId names an admin only on the admin-action events; on any
  other event it is the run or conversation owner, so the row is activity
  and loses its free text even after the run is pruned
- the retry stop is decided by the UPDATE against the row's live status
- colleagues' subscriptions are matched with normaliseAddress in code, not
  re-implemented in SQL, and cleared before the delivery passes
- the erasure transaction gets a 60s timeout
- an approver-only export row says the subject was an approver
- one shared createdAt-then-id comparator
- documents the pruned-run limit (t-792)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the review-round-1 fixes; smoke the pruned-run and padded-address cases (t-783, t-786)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address code-review round 2 on delivery erasure (t-783, t-786)

- in-process retry timers skip a row settled since they were armed, so an
  erasure's stop (and t-739's) holds
- an admin action's free text is never exported to the run owner
- the webhook export source reads one table at a time, as exportUserData does
- the match selects only the columns it needs and orders in SQL
- docs: subscription matching is normalised in code (the step-7 edit had
  not been written), the in-flight-delivery limit, the approver flag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the review-round-2 fixes (t-783, t-786)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address code-review round 3 on delivery erasure (t-783, t-786)

- the workflow.failed a force-fail emits carries the admin's note too;
  never exported to the run owner
- one AWAITING_RETRY_STATUSES constant for the timers, the SQL stop and the
  address pass
- erasedPayload narrows the payload once
- docs and CHANGELOG no longer overclaim the stop; the open cases are t-793

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The route reads the global cap through getGlobalCapState now; the tests
still mocked getMonthToDateGlobalSpend, so the global-cap case fell into
the route's catch and returned null budgets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w's other findings (t-788)

Code review of t-788 found ten issues. All are fixed here.

- The org and global caps applied only to chat. The provider manager's
  Proxy now runs a spend gate (assertWithinSpendCaps, new
  lib/orchestration/llm/spend-caps.ts) before every vendor call, beside the
  eligibility gate. Workflows, the orchestrator, evaluations, judges,
  ingestion and jobs now stop at the caps. A refusal is
  SpendCapExceededError, code org_budget_exceeded / global_budget_exceeded.
  It counts as a request fault, so there is no failover and no breaker
  failure, and ExecutorError keeps the code and never retries it.
- A cap of 0 refused local models too. The gate never refuses a local
  provider. Chat holds a refusal while the turn is on a local primary and
  makes it only if the turn would reach a paid provider.
- The agents list ignored the org cap. It now applies each agent's org cap,
  one lookup per org, and reports orgCapExceeded.
- checkBudget ran its cap lookups one after another. The agent, org and
  global reads now run in parallel.
- The global total was an unscoped month-to-date sum on every call. The
  month-to-date totals are now cached for 10s, and logCost adds this
  process's own rows at once. The cap values are cached for 30s, and the
  orgs and settings PATCH routes clear them in their own process.
- Org cap changes left no audit entry. The PATCH now writes
  org.quotas.update with the caps it replaced, read inside the write's
  transaction (updateOrgReportingPrevious).
- { monthlyBudgetUsd: undefined } was stored as quotas: {}. A slice is now
  empty when every key is undefined (definedEntries), in writeOrgQuotas,
  applyOrgSettingsPatch and the lifecycle log line.
- Three copies of the UTC month-start helper are now one, utcMonthStart.
- The unused applyRetentionPatch wrapper is gone. Its tests now call
  applyOrgSettingsPatch.
- The two-org smoke compared a 24-hour total with a month-to-date one.
  Both totals now start at the start of the month, and the smoke also
  proves the gate per org.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A chat turn that goes on, on a local model, past a reached cap now holds
  every paid call it makes (withPaidCallHold), so paid embeddings, the
  summarizer and tools stop at the agent's cap as the main stream does.
- A cap that stops a turn part way persists the error marker, sends the
  budget webhook and wipes the partial answer, as any refusal does.
- A paid primary refused by a cap fails over to a local fallback: provider
  selection passes over refused paid candidates, in getProviderWithFallbacks
  and in chat's mid-stream failover.
- GET /costs/alerts shows the every-org spend only to the install org.
- The agents list skips an unreadable global cap instead of dropping every
  budget; one pair of skip-on-failure readers serves all three callers.
- isLocalProvider reads the provider row instead of building a client.
- The global cap is read from the settings cache, which the backup importer
  now clears too; the second cache and forgetGlobalCap are gone.
- Budget refusal text comes from the error registry, so the pre-turn and
  mid-turn refusals say the same thing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-budget-cap

Per-org monthly AI budget caps, held on every paid call (t-788)
…s (t-789)

An org's Org.settings.quotas.requestsPerMinute is a second counter in
applyRateLimit: the matched per-caller rule runs first, then the org's
window. The org comes only from a verified principal — the session's
activeOrgId, or the org column of the key or token row the credential
lookup found — never the x-sunrise-org header. Skipped at single, for an
org with no cap, and under /api/v1/admin/**, where a full cap is raised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Count a user only against an org they can enter: the session's user or a
  user key's owner must be an active member (the guards' rule, cached 30s,
  read only for orgs with a cap). A removed member's session, or anyone on
  a resolver-named hostname, can no longer hold an org at its cap.
- Take a session's org in the guard's precedence: the fork's tenant
  resolver, computed from the request, else activeOrgId.
- Stop counting embed-widget traffic; the token is public.
- Never count POST /api/v1/orgs/switch, the way out of a full org.
- Replace the timestamp-list window with a two-bucket sliding estimate, so
  a check is constant work whatever the cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Count a user API key on session-keyed routes against its org: with no
  session, look the key up for its org only; the bucket stays the IP's.
- Give an admin-scoped key no org, as enterApiKeyOrg does.
- Bound the cap cache with an LRU (a resolver can name arbitrary org ids).
- Set Retry-After to when the sliding estimate has room, not the minute end.
- Never count GET /api/v1/orgs, which the switcher needs.
- Skip the tenant resolver at single; fix the stale schema comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A signed-in request carrying a user API key counts against the key's org,
  as withAuth reads the key before the cookie.
- Only sk_ keys count on session-keyed routes; those routes refuse MCP keys.
- Correct the docs: the org window is keyed by org id, not a mw:org token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-request-cap

Cap each org's API requests per minute on top of the per-caller limits (t-789)
getOrgUsage() rolls AiCostLog up by org for one UTC calendar month, in
the system scope at multi, with detached NULL-org rows in their own
bucket and each org's quotas.monthlyBudgetUsd beside its spend. At
single it is one install-org row.

GET /api/v1/admin/orchestration/costs/orgs serves it, install org only
at multi (the rule org caps are written under). The costs page shows a
per-org table when there is more than one org; the org detail page shows
month-to-date spend against the cap from the same endpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…777, t-778, t-780, t-785) (#993)

* Reduce outbound URLs in every logged string, and collapse colon-joined secrets (t-778)

The logger's sanitiser now passes every string it writes through
loggableUrlsInText, so a tokened webhook URL under any key, in a message,
or in an error's message or stack is logged as origin + collapsed path.
A path segment containing ':' is judged part by part, so a Telegram bot
URL no longer logs its secret. fetch-error.ts reuses the shared helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop a logger test that could not fail (t-778)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hide webhook secrets in served traces, notification events and audit entries (t-777, t-785)

- withDisplayUrls shortens URL-bearing trace values where a trace is
  served (execution detail and live, report.md, the approver's status
  page, the supervisor review); the persisted trace keeps working URLs.
- The workflow_notification event carries the step's reduced URL.
- Webhook subscription audit entries name the URL origin or the id and
  reduce url/emailAddress in the changes diff; the create log line no
  longer carries an email subscriber's address.
- The fetch-url audit entry records the reduced source URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the reduced source URL on the fetch-url audit entry (t-777)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the page address's secrets out of analytics (t-780)

Page views, and the addresses providers attach by themselves, keep only
origin, collapsed path and the attribution query parameters (owner
ruling b): utm_*, gclid, fbclid. Applied at the React page() chokepoint,
in each provider's page() and fallbacks, via gtag 'set' for GA4's later
events, PostHog before_send for its own properties, and server-side
tracking's Referer context. Plausible loads its manual script so it no
longer records page views by itself with the full address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document URL reduction in logs, traces, audit entries and analytics (t-777, t-778, t-780, t-785)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let the logger import only import-free modules, checked (t-778)

The guard keeping the logger's static imports out of the browser bundle
was absolute; the sanitiser now needs the URL scrubber. It allows an
explicit list instead, and asserts each listed module reaches no other
module at runtime, so nothing behind it can pull in a Node builtin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address code-review round 1 on URL secrets (t-777, t-778, t-780, t-785)

- The executions list and the three run streams (execute, execute-stream,
  rerun) serve URL-bearing values reduced, as the trace routes do;
  inputData is reduced on the detail and review routes too.
- withDisplayUrls also matches a provenance source's 'reference'.
- The userinfo pattern stops at '"', backtick, '<>' and whitespace, so a
  URL in serialised JSON no longer reads across to a later field's '@'.
- PostHog before_send reduces autocapture's link targets.
- A same-origin URL change in a subscription audit diff is marked
  '(path changed)' instead of reading as a no-op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address code-review round 2 on URL secrets (t-777, t-778, t-780)

- runSupervisorAssessment reduces step outputs, input and output itself,
  covering the in-workflow supervisor step and the review route alike.
- The userinfo pattern allows quotes again but stops at a quote beside a
  separator, so a double-quoted password is stripped and a JSON field or
  list item is not read across.
- withDisplayUrls walks plain objects and arrays only, so a Date or
  Decimal in a live step output keeps its toJSON.
- PostHog's $direct referrer sentinel is left as it is.
- GA4's reduced address is set globally at init, not on the config,
  where it would outrank each page view's set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name the round-2 trace surfaces in the CHANGELOG and logging doc (t-777)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address code-review round 3 on URL secrets (t-777, t-780)

- The approver status route reduces step configs only: the chat approval
  cards post the last step's output to the agent as the workflow result,
  and a reduced URL there would be a dead link.
- report.md reduces inputData and outputData, which its renderer prints.
- A provenance reference is reduced only for an external_call source; a
  web_search citation stays a working link.
- Docs name PostHog heatmap and web-vitals data as out of reach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- Show the per-org table with one org when there is detached spend, so an
  erased org's spend, which still counts towards the global cap, is seen.
- Require a year of 1000 or later in `period`: Date.UTC reads 0-99 as
  1900-1999, so the report named one month and summed another.
- Say "Paid calls off" for a cap of 0 on the org page, as the table does.
- Drop 'use client' from the per-org table, which has no client state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-usage-metering

Show each org's monthly AI usage to platform admins (t-790)
The bulk route's where excluded system agents but not deleted ones, so a
deleted agent's id in the selection revived it and wrote it a version.
Add deletedAt: null, and report the ids the action could not reach
(system, deleted or unknown) as `skipped`. The candidates are now read
once with isActive, and filtered in code to the ones the action changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The proxy already applies the admin section cap, keyed on the session,
to every /api/v1/admin/** request. The DLQ list, stats, replay and
delivery-delete handlers also called adminLimiter.check on the client
IP, so each request counted twice and admins behind a shared NAT hit
429s early. Remove the four calls and the tests that mocked them.

No per-flow sub-cap for bulk replay: a batch is already bounded at 500
rows and 5 concurrent retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JohnD-EE and others added 27 commits October 18, 2026 18:10
…-847–t-849) (#1034)

* Fail evaluation runs where no grader scored any case (t-847, #921)

When the subject ran but every grader or judge threw, no case carried an
errorCode, so the all-cases-failed check passed and the run ended
`completed` with nothing scored. The cost estimator then sampled it as a
good run. Such a run now ends `failed` with
`summary.note = 'no_scores_recorded'`. A run with no cases, or where any
metric scored at least once, is unchanged.

The happy-path worker test only mocked one of the worker's two
case-result reads, so its aggregation always ran on an empty list. It
now mocks the resume-cursor read too, and aggregates the rows it
declares.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show failed evaluation runs without crashing the run detail page (t-848, #922)

A run the worker fails before aggregation (dataset changed, invalid
metric configs, preflight) stores a summary with only a note, but the
page typed metricSlugs and stats as always present and mapped over
metricSlugs, so opening one threw. They are now optional. The metrics
table renders only when both exist; otherwise the card says no metrics
were aggregated and shows the note, plus the preflight error when there
is one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show decisions on scheduled and inbound runs in approval history (t-849, #773)

Approval history read executions with `userId: session.user.id`, so a
run nobody owns (scheduled or inbound) never matched, and every decision
on one was missing, including the caller's own. The policy was never
asked either, so a fork that narrows unattributed reads had no say over
this page.

It now selects through `executionVisibilityWhere(session)`, the same
rule as the executions list, with the status filter in its own AND arm.
Recorded on the Approval queue feature: listing "decisions I made on any
run" was rejected because it means discovering other admins' owned runs
through the trace, the approver arm t-690 kept off the read surfaces.

The route leaves the ownerless-surfaces roster's known gaps (now empty),
and joins the policy-narrowing test beside the other execution read
surfaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review: approval-history scan window, run summaries (t-847–t-849)

- Approval history scans only runs whose trace holds a human_approval
  step (jsonb containment). With ownerless runs admitted, a schedule
  running every minute would otherwise fill the 1000-run window with
  runs that have no approval, and older decisions would drop off the
  page. Checked against the dev database: the filter matches the 31 of
  58 runs a hand count finds.
- A no_scores_recorded run also records casesFailed and
  dominantErrorCode when some cases errored before grading.
- The run detail page shows a worker crash's message beside its code,
  and its fallback no longer claims when the run stopped.
- The CHANGELOG and docs say a grader that skips leaves a run unscored
  too, not only one that throws.

Not changed: a named approver still does not see their decision on
another admin's run (the t-690 ruling, recorded on the Approval queue
feature), and the route's ownership declaration stays 'self', as on the
executions list it now matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Answer a hidden agent the same as an unknown one on chat token validation (t-850)

The validate-token route looked agents up without the consumer routes'
visibility filter, so an internal agent answered "does not require an
invite token" where an unknown slug answered "not found" — enough to
probe whether a named internal agent exists. Filter to public and
invite_only as the stream route does, so a hidden agent is not found.

Fixes #894.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Treat an async start-up registration as a failure, not a success (t-851)

createAppInitGate read a seam's returned promise as success: it latched
'ok' as soon as the call returned, kept what the init registered, and a
later rejection never reached onFailure. Authorization selects safe mode
and subject-sources refuses an Art. 15 bundle only from onFailure, so an
async initAppAuthorizationPolicy that rejected left the install on the
default policy with nothing reporting it.

The gate now refuses a thenable return as if the init had threw: roll
back, settle 'failed', log, run onFailure, and route any later rejection
to the log. Refusing rather than awaiting because the registries' reads
are synchronous. This breaks an async seam that happened to work; it was
outside the guarantee already, and the CHANGELOG tells forks to drop the
async. initApp (the boot hook) stays async and its docblock now says so.

Fixes #739.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse keeping executions for ever while cost logs expire (t-852)

The global settings schema, PATCH route and form skipped the retention
pair check whenever either side was null. Null means "keep for ever", so
executionRetentionDays: null with a finite cost-log window was accepted
and the cost breakdown emptied out under executions still on file.

All three now share costLogsOutliveExecutions(): null cost logs always
cover; null executions need null cost logs. Same rule the org route
already applied. Error messages name the fix.

Closes #839.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Follow the device theme until a visitor picks one (t-853)

The no-flash script, ThemeProvider's lazy initialiser and its first-mount
effect all saved the device's light/dark setting to localStorage as if
the visitor had picked it, so a device that later switched was never
followed again.

Only setTheme writes now. With nothing stored the provider listens for
prefers-color-scheme changes. lib/theme.ts holds the resolution and the
script text, so the layout and the hook cannot drift; a test runs both
over the same inputs. Stored values are kept, so existing visitors see
no change.

Closes #756.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Make erasure see the cleanup hooks forks register at start-up (t-854)

The erasure hook registry was a plain module Map with no lib/app seam.
instrumentation.ts, and so initApp(), loads in a separate module graph
from the route handlers that call eraseUser(), so a hook registered at
start-up, as the erasure doc said to, was never run and nothing logged.

The Map is now on globalThis, so hooks registered from initApp() are
seen. A new fork-owned seam, lib/app/erasure.ts initAppErasureHooks(),
runs once through createAppInitGate before the first read, with
rollback on a throw. Docs, roster, VERSIONING and CUSTOMIZATION name it.

Closes #691.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Assert the erasure seam ships empty alongside the other lib/app seams (t-854)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cover the theme provider's blocked-storage, server and no-provider paths (t-853)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check the global retention pair only when a PATCH names it (t-852)

A row saved before #839 tightened the rule must not 400 a PATCH that
changes something unrelated, as on the org route. Also un-export a
file-local message and fix a test path in the theme docblock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Note that a PATCH naming neither retention window is not checked (t-852)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Never hand out more heap than was asked for or the machine can afford (t-856)

resolveHeapMb floored at v8's heap_size_limit, which is only Node's
unaided default when nothing capped this process. Under an inherited
--max-old-space-size it reads the parent's cap plus V8 overhead, so a
6144 request came back as 6336, and a large parent cap on a small box
stepped over the 75%-of-RAM clamp the function exists to enforce.

unaidedLimitMb now reports no floor when NODE_OPTIONS or execArgv
carries a cap, and the clamp applies last so it holds whatever the
floor says. Fixes #711.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Make the heap-cap tests independent of the host's RAM (t-857)

Two tests asserted the applied cap against DEFAULT_HEAP_MB while running
the real resolver, which clamps to 75% of physical memory, so both went
red on the ~8GB private runner ci.md tells forks to size for.

main() now takes the resolver as an injectable dep, and its test pins
what main owns: the ask passed in, the answer passed on. The chunked-lint
child-env test compares against the resolver's own answer on the host
instead of a fixed number. Fixes #700.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cap the type-check's heap so a cold run does not run out of memory (t-855)

tsc was kept out of run-capped.mjs on a 1.64-1.75 GiB peak, but with
incremental on that was a warm run. Cold, base Sunrise peaks at 2.74 GiB
RSS and a ~1.9x fork aborted at 4.40 GB, and type-check runs first in
validate, so the abort stopped the gate before lint or format ran.

type-check now goes through the wrapper like lint. Its argv is static,
so the Windows shell-quoting caveat does not apply. CI is unchanged: the
workflow sets NODE_OPTIONS and the wrapper stands down. The docs restate
the measurement as cold. Fixes #693.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pin the emails seam in the shared test setup so fork templates leave core tests green (t-858)

tests/setup.ts pinned lib/app/brand but not lib/app/emails, so a fork
that filled emailOverrides turned registry.test.tsx red with nothing in
the fork it could change. The seam is now pinned to {} suite-wide, like
brand; defaults.test.ts reads the real scaffold through importActual so
'seams ship empty' can still fail in a fork; and registry.test.tsx
restores the pin with doMock instead of removing it with doUnmock.

Checked by filling the seam: without the pin the registry fallback test
fails; with it only the ships-empty row does. Fixes #895.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop the feature-flag delete test updating state after teardown (t-859)

The test mocked apiClient.delete with a real 100ms timer and returned as
soon as the call started, so the promise could resolve after happy-dom
was torn down and the component's state updates failed the run with
'window is not defined'.

The delete is now held open by hand and settled inside the test. The
test was also named for a disabled-button state it never asserted, and
which is never visible: the dialog closes on click. It now checks what
the component does: the row stays while the delete is pending and goes
once it completes. Test-only; React 19 already ignores state set after
unmount, so no mount guard in the component. Fixes #925.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address code review: keep a command-line heap cap, guard the emails pin

- unaidedLimitMb ignored execArgv for the floor only when NODE_OPTIONS is
  capped. A cap on the wrapper's own command line is not inherited by the
  child, so dropping the floor there cut an explicit 12288 back to 6144.
- The defaults.test.ts pin guard now covers lib/app/emails as well as
  brand, so a future doUnmock of the emails pin fails a test rather than
  relying on a comment.
- chunked-lint.mjs's withHeapCap comment states the new floor-then-clamp
  order.
- CHANGELOG entry for type-check moving behind the heap cap, following
  the precedent set when lint did.

Not changed: totalmem() vs a container's cgroup limit, and the uncapped
floor counting V8's non-old spaces, both predate this branch; the
deleting-state coverage, because the dialog closes on click and that
state is never visible; and the wiring tests that compare against the
resolver, since resolveHeapMb's own tests pin its values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…all-screen header nav (t-860, t-861, t-862) (#1038)

* Trim before checking in every Zod string chain (t-860, #713)

Zod runs a string chain's checks in the order written, and .trim() is
one of them. 88 chains trimmed after .min()/.max()/.regex(), so "   "
passed .min(1) and was stored as "". Every chain now trims straight
after z.string().

The reorder was done by an AST codemod rather than by hand: one move per
chain, 116 lines each way. trim-order.test.ts parses every z.string()
chain under app/, lib/ and components/ and fails on a length, pattern or
refinement check before .trim(); it is in ALWAYS_RUN_TESTS because the
change it catches is a new schema no import chain reaches. Docs and the
form-builder template taught the old order and are corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Expect whitespace-only names to be rejected in the user schema tests (t-860)

Both tests pinned #713 as intended behaviour. The user-schema one said
a whitespace-only name was "simply omitted"; it was stored as "",
blanking the user's name on a profile PATCH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Make "Try again" on error boundaries re-fetch the page (t-861)

Every boundary passed Next's reset(), which re-renders without
re-fetching, so a page whose server-side data load failed failed again
the same way. Next 16.3 hands boundaries a stable retry() that
re-fetches the segment first; all five boundaries now use it.

This is what makes the next commit's error path recoverable: an admin
page whose main data fails to load will throw to the admin boundary,
and its "Try again" has to actually try again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add a server-side loader that tells a failed load from an empty one (t-861, #518)

Admin pages wrapped each serverFetch in a try/catch returning [] or null,
so a 500, a timeout or a bad base URL rendered as "No agents yet", and a
detail page's 500 rendered as a 404.

lib/api/server-load.ts has two entry points:
- requireServerData() for a page's main data: returns it or throws to
  the route's error boundary, and with { notFound: true } renders the
  not-found page on a genuine 404 only.
- loadServerData() for a secondary panel: never throws, returns a typed
  failure, and the page renders <LoadFailedNotice> in that panel's place.
Both log every failure, so pages lose their own catch-and-log.

Three pages move to it, one per shape: the agents list (main data), the
provider edit page (detail, 404 vs failure), and the orchestration
dashboard (independent panels; an empty quarantine panel or a missing
budget banner is the all-clear signal, so a failure must not look like
one). Their tests now fake only serverFetch, with real Responses, so the
real parser and loader run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the server-load recipe and the INTERNAL_API_URL case it surfaced (t-861)

.context/api/examples.md now says which loader to use for which data,
with the three reference pages. The dashboard doc describes what each
failed panel renders. .env.example said a local reverse proxy needs no
INTERNAL_API_URL in every mode; that holds under npm run dev only, and
a local production build behind one is the setup that hid #518.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Record the loader and the boundary's retry prop in docs and CHANGELOG (t-861)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show load failures on the user, org, member, logs, overview and support-access pages (t-861)

Users and orgs lists, logs and the user/org detail pages throw to the
admin boundary on a failed load. A malformed id (the API's 400) still
renders not-found, as before; only a 404 or 400 does. The overview's
stats show a notice instead of skeletons that never resolve. Members,
support access and the org detail's provider and usage panels keep the
'could not be loaded' messages they already had, now driven by the
loader and logged. Five of these pages logged nothing on failure.

Invitations on the users page fail the whole page for now: the tabs
component has no place to show a failure in that tab alone, and an
empty 'Pending invitations' tab reads as none pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show load failures on the agent, profile, capability and provider pages (t-861)

List rows and detail records are main data; a detail page 404s only on
a 404. The agent pages' quarantine banner and evaluation trend, and the
category list on the capability forms, show a notice when they fail to
load: an empty quarantine banner reads as nothing quarantined. A
capability's 'used by' list is main data because the cards that show it
would otherwise state 0 agents as fact. The agent form's existing
free-text fallback for a failed provider list is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show load failures on analytics, approvals, conversations, costs, executions and settings (t-861)

Panels whose empty state reads as an all-clear (no unanswered questions,
no budget alerts, nothing awaiting approval, 0 running) are main data
for now, because the view components that lay them out cannot show a
failure in one panel; the rest show a notice. The settings record is
main data, so its forms are never pre-filled with defaults an admin
could save over the real settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show load failures on the evaluation, experiment, knowledge and learning pages (t-861)

Lists, detail records and the option lists a form cannot work without
are main data. Option lists that only feed an optional control (case
generation, the agent filter, related patterns, the pairwise judges)
show a notice. A pattern number below 1 is answered without a fetch,
since the API's 400 would now reach the error boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show load failures on the event-subscription, MCP, trigger and workflow pages (t-861)

MCP settings that fail to load or parse now throw instead of filling the
form with defaults, and the MCP dashboard no longer shows a failed
settings load as 'Disabled'. The workflow builder's option lists show a
notice above the canvas. The workflows list keeps its existing error
banner, now driven by the loader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Render not-found for a malformed id on every detail page (t-861)

The detail routes validate their id and answer a malformed one 400, which
the old pages showed as a 404. With requireServerData({ notFound: true })
it reached the error boundary instead, and was logged as an error.
notFound now covers a 400 as well as a 404, and the loader logs a 400 as
a warning. The user and org pages, which handled this themselves, use the
option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let the users, new-dataset and MCP pages fail one panel at a time (t-861)

In the first pass these failed the whole page when a side panel's data
did not load, because the component laying the page out had nowhere to
show a failure in one tab. Pending invitations, the case-generation tab
and the MCP card counts now show a notice (or '—' on a count) where they
would be, and the rest of the page works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let the providers, capability and event-subscription pages fail one panel at a time (t-861)

The model matrix, a capability's 'used by' list and the dead-letter
queue each show a notice in their own tab when they fail to load. With
the agent list unknown, the quarantine card says it applies to every
agent with the capability attached instead of stating a count; only the
capability load decides not-found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let the costs, analytics and executions pages fail one panel at a time (t-861)

Budget alerts, each analytics panel and the live-engine snapshot show a
notice in their own place when they fail to load; no single analytics
endpoint can take its page down. The cost summary stays main data: it
feeds five panels and is what the page is for. A failed live-engine
snapshot does not mount the polling panel, which has no state between
'no data yet' and '0 running'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Give the app header a labelled small-screen menu (t-862)

Below 640px the header navs hid each label with `hidden sm:inline`. The
icon is aria-hidden, so every link lost its accessible name, an item with
no icon rendered as an empty box, and nothing in the row could shrink.

PublicNav and ProtectedNav now resolve their items and hand them to a
shared HeaderNav: labelled inline links from `md` up, a named dropdown
menu below it. The menu lives inside the nav rather than in a second
AppHeader slot, so any caller passing `navigation` keeps its nav on every
screen size. AppHeader lets the brand truncate and holds the actions'
size.

Closes #862

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cover AppHeader's default brand (t-862)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document HeaderNav and the header's small-screen layout (t-862)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Render no header nav when there are no items (t-862)

A fork whose nav items are all admin-only left non-admins below md with a
menu button that opened an empty menu. HeaderNav now renders nothing for an
empty list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address code-review findings on the server loaders (t-861)

- Trigger pages asked the list routes for 200 rows; the routes cap limit at
  100 and answered 400, so both pages always hit the error boundary.
- The executions list dropped bad workflowId/status filters into a required
  load; it now drops them, so a stale bookmark shows every execution.
- loadServerData logs a 404 (the panel notice says it was logged), takes an
  expected-status option (the costs page's 403 is no longer logged), and
  passes Next's control-flow errors through with unstable_rethrow.
- requireServerData's notFound covers only a 400 whose validation error
  names the id, not every 400.
- Malformed graders/agents replies show the panel notice instead of
  crashing the page; the agent page shares one cached agent fetch; the
  dashboard's activity feed no longer waits on the first wave.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address second code-review round on the server loaders (t-861)

- loadServerData honours expected: [404]; the pattern-detail page passes
  it, so a pattern with no chunks no longer logs a warning per view.
- The agent page's metadata fallback lets Next's control-flow errors
  through with unstable_rethrow.
- requireAllServerData() reads every page of a list route, or throws; the
  trigger pages use it, so past 100 rows no agent or workflow is dropped
  (including the trigger's own agent).
- The executions list says when a filter from the URL was ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address third code-review round on the server loaders (t-861)

- Executions table ignores a URL status its menu doesn't offer
- Pattern page validates the number with the route schema
- Agent page and dashboard log list-less panel replies; a quarantines
  reply without items shows the notice instead of "nothing quarantined"
- Org usage loads through loadServerData with 403 expected
- ProvidersTabs takes a non-null initialProviders

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… (t-863) (#1039)

* Make the /pre-pr router-mock scan catch shorthand and alternate casts (t-863)

/pre-pr step 4m was a Python block in pre-pr.md that matched text. In a fork
it reported CLEAN over 16 complete router literals and 17 casts (#936): its
member regex needed a colon, so shorthand members were never counted; it
located literals by a `prefetch:` anchor, so a shorthand prefetch hid the
whole literal; and its cast rule grepped one spelling while the offenders
wrote `ReturnType<typeof vi.fn>`.

The rules now parse with the TypeScript compiler (scripts/ci/router-mocks.ts,
lifted from the downstream fix in resparkable#106) behind a CLI,
`npm run check:router-mocks`, which `validate` and CI run and 4m now calls.
Sunrise's own suite is clean under it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix router-mock checker misses and false positives from review (t-863)

Code review round 1. Fixed, each with a test that fails without it:
- a file naming 'next/router' anywhere was skipped even when its
  next/navigation mock used a template literal (a silent pass);
- a cast returned from inside a block in a mockImplementation body was missed;
- a namespaced `mocks.createMockRouter()` was not recognised as the factory;
- all six spies passed to the factory as overrides were flagged, though that
  is what the CLI's own advice tells such a test to write;
- `/* router-mocks-ignore:\n */` passed as a directive with no reason.

Not changed: an aliased `const u = vi.mocked(useRouter)` is the stored-spy
limit #936 already documents; a scan root other than tests/ matches where
Sunrise keeps its tests and the output names that root; a read-back cast to
MockRouter and a widened factory call are rare and have the ignore directive;
the CI step's code-only gate matches every sibling check, and validate runs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Catch router casts in conditionals; accept any ignore reason (t-863)

Code review round 2, on the round-1 fixes:
- a partial router cast returned as one branch of `c ? X : Y`, or as a
  `??`/`||`/`&&` operand, was still missed, though round 1's comment claimed
  "any return X";
- round 1's directive regex rejected genuine reasons starting with `/` or `*`;
  a block comment's closing `*/` is now stripped before testing instead;
- the nearest-function walk is pinned by a nested-function-with-block test.

Not changed: overrides passed to the factory through a variable need data
flow tracking and have the ignore directive; any `x.createMockRouter()` counts
as the factory; a pages-router file naming its module in a template literal is
scanned, by design, so the check errs towards looking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…'s Hub bootstrap out of CLAUDE.md (t-864, t-866) (#1043)

* Move Sunrise's own Hub bootstrap out of the CLAUDE.md forks inherit (t-866)

CLAUDE.md carried Sunrise's Hub project identity and process tier between
hce-hub:bootstrap markers, so every Hub-coordinated fork read two project
identities after a sync, or had to delete the block on every upstream
regeneration. The block now lives in a gitignored CLAUDE.local.md per
checkout; CLAUDE.md keeps only a generic pointer, and CUSTOMIZATION.md's
first steps tell a fork where its own bootstrap goes.

Fixes #845

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse version-pinned experiment variants until a saved version can run (t-864)

The run path only loads the live agent, so a variant pinned to an
AiAgentVersion was scored against the live config and a version A/B
named a winner between identical setups. Create now answers 400 for any
pinned variant, and run answers 400 for an experiment stored with one
before writing any evaluation run or session. Unpinned experiments are
unchanged; the create form and experiments doc now say every variant
runs the live agent (an A/A check).

Refusing every identical-config experiment, as the issue's stop-gap
proposed, was rejected: the UI never sends a pin, so it would have
disabled experiments entirely.

Fixes #857

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address code review: keep the gates in CLAUDE.md, name the one-time fork conflict, refuse empty pins

- The gate order (/pre-pr, /security-review, /code-review, format) is a
  platform rule, not Hub identity, so it stays in the committed CLAUDE.md
  rather than only in a gitignored CLAUDE.local.md.
- A fork that replaced the block between the old markers conflicts once on
  the sync that removes it; CLAUDE.md and CUSTOMIZATION.md now say so
  instead of claiming it merges cleanly.
- Create refuses any agentVersionId that is present, so an empty string is
  refused instead of being silently dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ment, coverage note, MCP client docs, seam table (t-867 to t-872) (#1046)

* Cap the CI test jobs at 30 minutes so a hung run is cancelled (t-867)

A vitest worker that deadlocks during module evaluation (an import cycle
closed through a vi.mock async factory) hangs before any test starts, so
no vitest timeout fires and test-full / test-changed sat for GitHub's
six-hour default (#843). Both now carry timeout-minutes, default 30,
raisable through the CI_TEST_TIMEOUT_MINUTES repo variable so a fork with
a slower suite need not edit ci.yml. Shards take 2-4 minutes on Sunrise.

Corrects the docker job comment and ci.md's convention that every other
job runs processes that end on their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reject always-run test entries that point outside the repository (t-869)

Dropping the tests/ prefix check (#763) also dropped repo containment: the
runner keeps an entry when existsSync(resolve(root, path)), and resolve
leaves root freely, so ../other/x.test.ts or /tmp/x.test.ts passed every
check and was then silently skipped (#765). validateAlwaysRun now rejects
an absolute path (POSIX, Windows drive, backslash root) or one with a ..
segment, with a regression test per form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Correct the coverage-widening note and the exemption reasons (t-870)

v0.11.2's notes said only .mjs joined the per-file coverage floor, but
coverageTargets admits .js, .jsx, .cjs and .mjs; a fork that checked as
told missed its .jsx (#698). The shipped note stays as it was; the
correction goes in Unreleased so the next release carries it.

Two NOT_EXEMPT_DESPITE_COVERAGE_EXCLUSION reasons claimed "4f still
asks" about files it cannot read: sourceStem handles only .ts/.tsx.
lib/app/eslint.config.mjs's reason now says 4f never reports it today
and why the entry stays here rather than in PATH_EXEMPTIONS; the spikes
reason scopes the claim to the .ts spikes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop telling MCP users to paste keys into Claude Desktop (t-871)

The MCP guide and the dashboard's Client Configuration card both said
an smcp_ key goes into claude_desktop_config.json. Claude Desktop's
remote connectors have no header field, so that connection can never
work (#896, split from #820). The guide now names the clients that can
send the Authorization header (Claude Code and Cursor, with examples),
says sign-in-only clients cannot connect until OAuth lands (#820), and
moves that case into the "When OAuth becomes necessary" table in place
of the row claiming no client refuses bearer auth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* List footer.ts and tenant-resolver.ts in the fork guide's seam table (t-872)

CUSTOMIZATION.md §4's "Edit this file" table had rows for 38 of the 40
lib/app/ scaffolds. A fork looking there for the copyright line found no
footer.ts and could edit the platform footer instead, carrying a conflict
on every upgrade (#735). Both rows added.

So it cannot drift again, versioning-seam-coverage.test.ts (#732's derived
roster) now also checks the table against lib/app/ in both directions,
reading only rows of the table, not paths in the prose around it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bound the CI Next.js build cache so it starts afresh past a limit (t-868)

The Build job restores .next/cache through a restore-keys chain and saves
it under a per-commit key, so each cache is the last one plus this
build's output until package-lock.json changes. Sunrise's grew from
~0.5GB to ~2GB compressed between lockfile changes; a fork's reached
5.5GB and a 37-minute Build was cancelled (#856).

A step before npm run build measures the restored cache on disk and
deletes it past CI_NEXT_CACHE_MAX_MB (default 2048), logging the size
either way. 2048 is about three times a fresh Sunrise cache, measured at
674MB on disk (501MB compressed) from a clean local build. The variable
reaches the script through env, not interpolation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name CUSTOMIZATION.md in the seam-coverage guard's always-run reason (t-872)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let a re-run save a fresh build cache; say the cache limit is on-disk (t-868)

Review round 1. The Next cache key ended in github.sha, so re-running a
commit restored an exact primary-key hit, and actions/cache skips its save
on one: a cache the bound step discarded was never replaced. The key now
ends in github.run_attempt too.

ci.md now says the limit is measured on disk while the cache list and the
restore log report compressed sizes (Sunrise's ~2GB is ~2.7GB on disk),
and why smoke / smoke-multi stay uncapped under the "cap anything that can
wait forever" rule (their Postgres is a health-checked service container).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop naming Claude Desktop as a working MCP client anywhere (t-871)

Review round 1. The MCP page, its resources and prompts screens, the admin
MCP doc and the top of mcp.md still gave Claude Desktop as the example of
a client that connects, contradicting the Client Configuration card on the
same screen. Examples now name Claude Code or Cursor, and the admin doc
says sign-in-only clients cannot connect until OAuth (#820).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Give test-changed a 60-minute default; read the cache limit as base 10

Review round 2 (t-867, t-868). test-changed runs the whole suite unsharded
when a lockfile or vitest-config change makes --changed select everything,
so it now defaults to 60 minutes rather than a shard's 30;
CI_TEST_TIMEOUT_MINUTES still sets both. The cache-bound comparison forces
base 10, since bash read a leading-zero limit (08, 0100) as octal and
either failed the step or silently used 64MB.

ci.md no longer claims the smoke jobs are safe uncapped: their service
start-up is health-checked, but nothing bounds the scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Suggest naming an MCP key "Cursor", not "Claude Desktop" (t-871)

Review round 2: the key form's example named a client that cannot connect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rewrap the CI_TEST_TIMEOUT_MINUTES CHANGELOG bullet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…(#1047)

* Record an account deletion durably and finish it on the tick (t-842)

A deletion now records its intent on the User row and disables the account
(sessions deleted, API keys revoked, sign-in refused) in one transaction
before any erasure work starts. If the erasure fails or the request is
killed, the pendingErasures platform job finishes it. erasureNextAttemptAt
is both the lease and the backoff, so two attempts never run at once.

Both delete routes go through requestUserErasure(); a repeat while another
attempt holds the lease is a 409. An admin whose deletion is pending no
longer counts towards the last-admin check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Prove pending erasures against Postgres: smoke:erasure-pending (t-842)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document pending erasures, the pendingErasures job and the 409 (t-842)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test pending erasures: the service, sign-in refusal, retrier job and both routes (t-842)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Take a lease-less pending erasure first, and correct the actor-fallback comment (t-842)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix code-review round 1 on pending erasures (t-842)

- A retry whose requesting admin has since been erased names the person
  instead: the admin's id on an audit row failed the FK every attempt, so
  the person was never erased. Proved in smoke:erasure-pending.
- The lease runs from the claim's own clock, not the retrier run's start.
- The failure backoff is written only while this attempt's lease stands, so
  it cannot cut short a newer attempt's lease.
- The retrier runs every tick, one erasure per run, and wakes the idle gate
  on a claim and while anything is pending.
- resolveApiKey refuses a key whose owner is being deleted, closing the key a
  cached session could mint after revocation.
- Index erasureRequestedAt, which the retrier filters on, instead of
  erasureNextAttemptAt (new migration; the first is already applied).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix code-review round 2 on pending erasures (t-842)

- The subject-access export leaves out a pending erasure's requester (another
  person's id) and the retrier's attempt count and next attempt.
- Self-delete answers a repeat with a 409 it returns rather than throws, so it
  is not logged as a failure, and clears the cookies: the sessions are gone.
- The last-admin count always includes the caller, so a caller whose own
  deletion is pending is not told they are the last admin.
- The "erasure requested" log names the actor and reason the erasure records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix code-review round 3: erasing an admin clears them as a requester (t-842)

erasureRequestedBy has no FK, so no SetNull reached it, and an erased admin's
id outlived them on every pending erasure they had asked for. eraseUser now
clears it in its transaction. Proved in smoke:erasure-pending. Documents the
export omission from round 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say what a 409 and a failing erasure cost, in the pending-erasures doc (t-842)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Compute the MCP key rotate test's expiry relative to now (t-873)

The test sent a fixed 2027-01-01 expiry to a schema that requires
expiresAt to be in the future, so it would start failing on that date.
Checked by pinning the clock to 2027-06: the old test returns 400, the
new one passes.

Fixes #904

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Skip the useLocalStorage write when the value is unchanged (t-874)

An updater returning prev still serialised, wrote localStorage and
dispatched the same-tab event. The setter now returns early when the
resolved value is Object.is-equal to the current one. The test pairs the
no-op with a real change on the same spies, so it cannot pass because
the spies see nothing (the first draft spied on Storage.prototype, which
happy-dom does not use, and passed vacuously).

Fixes #937

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Install tw-animate-css so the UI overlays animate (t-875)

The components/ui/ overlays (dialog, alert dialog, dropdown, popover,
select, tooltip) use animate-in / fade / zoom / slide-in-from-* classes
that nothing defined, so they appeared instantly. Adds tw-animate-css
1.4.0 beside tailwindcss and imports it from globals.css. Checked by
compiling globals.css through @tailwindcss/postcss: every class the
overlays use is generated with the import, and none without it.

Fixes #682

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Make the local lint out-of-memory remedies take effect (t-876)

Three traps, each a silent no-op for someone following the docs:

- chunked-lint read only LINT_CHUNKS, but every doc names CI_LINT_CHUNKS
  (only ci.yml bridged the two). It now falls back to CI_LINT_CHUNKS;
  LINT_CHUNKS still wins so CI's bridge is unchanged.
- withHeapCap ignored NODE_HEAP_MB, so lint and lint:ci disagreed when it
  was set. It now resolves it through run-capped's own parser.
- lint-memory-probe printed an arm with no measurement as "0.00 GB ok",
  so the arm the OS killed looked like the best. It now keeps the exit
  code and reports KILLED(rc) or NO-DATA, with "n/a" for the peak.

The two script fixes have tests that fail without them. The workflow
change was checked by simulating its verdict block; it runs only on
manual dispatch.

Fixes #699

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Give the three lib/app guards one rule for what a scaffold is (t-877)

versioning-seam-coverage classified lib/app/ with a deny-list that
counts directories and any extension; defaults.test kept top-level
.ts|.mjs files; fork-init-seams kept top-level .ts. A nested
lib/app/<name>/ seam or a .mts file was caught by the first and silently
skipped by the other two. lib/app/ is flat, so they agreed by accident.

The classifier moves to tests/helpers/app-scaffolds.ts with a recursive
scaffoldSourceFiles() for the export scans, and all three read through
it. The helper's test drives a temp fixture with a nested seam and a
.mts file. Checked end to end by dropping lib/app/zz-sub/seam.ts and
lib/app/zz-theme.mts into the tree: all three guards now fail (before,
only the versioning one did); the files were removed afterwards.

Fixes #734

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document that useLocalStorage skips unchanged values (t-874)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Compare useLocalStorage's skip against storage, not state (t-874)

The Object.is check against in-memory state dropped three writes the
old code made: persisting a never-stored default (so another instance
with a different default never heard), retrying after a failed write,
and an updater that mutated prev in place. The setter now skips only
when storage already holds the same serialised value, which still
covers the updater-returning-prev case from #937. Each new test fails
against the previous setter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pin the heap test's machine and name the chunk variable read (t-876)

The NODE_HEAP_MB test guarded itself with a resolveHeapMb comparison
that collapses on a box under 4GB or under a large inherited heap
limit. withHeapCap now takes the machine facts as deps, and the test
pins them. A bad chunk count is reported under the variable that
supplied it, so a stale LINT_CHUNKS overriding CI_LINT_CHUNKS is
visible. The probe's result column is widened for KILLED(137).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the shared APP_DIR in the seam guard, with a separator-safe prefix (t-877)

The guard kept its own APP_DIR beside the helper's, and its startsWith
exclusion would also have dropped a sibling such as lib/application/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Skip a useLocalStorage set only when state and storage both match (t-874)

Comparing against storage alone stopped broadcasting a set that an
out-of-step instance needed, and read storage on every keystroke. The
skip now needs the value unchanged in memory and already stored, and
reads storage only in that case. The hook doc says an in-place
mutation persists but, like useState, does not re-render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read and parse the chunk count in one call (t-876)

chunksFromEnv now parses as well, so main destructures deps once, and
the default name in parseChunks no longer blames LINT_CHUNKS for a
value a caller took from CI_LINT_CHUNKS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Return a 400, not a 500, when an invitation delete has a malformed address (t-878)

DELETE /api/v1/admin/invitations/:email decoded its segment unguarded, so
a stray `%` threw URIError and became a 500. The decode is now caught as a
ValidationError, and the result must be an email address. It is not trimmed
or lowercased, so a valid address is looked up exactly as before.

Fixes #954

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave built-in agents out of the agent export, so the file imports again (t-879)

A built-in agent starts with an empty provider and model, which the bundle
schema rejects, so an export that included one failed import as a whole.
The import would have skipped those agents anyway, and the backup export
already leaves them out. The agent export now does the same. A selection of
only built-in agents is a 400, and the agents table shows that reason
instead of its generic "try again" message.

Fixes #721

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let an admin reopen the PDF preview for a document left in Needs Review (t-880)

Closing the PDF preview left the document in pending_review, and its
Review button opened the chunks view, which is empty before confirmation.
The only way back was for the original uploader to upload the same file
again. Review now reads the document and reopens the preview from the
extraction the upload already stored, so any admin can confirm or discard
it. If the stored text is missing, the list says so.

Fixes #807

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop the API response helpers inviting a public Cache-Control override (t-881)

The successResponse() docblock and the API docs said a route could
replace the private default with a public directive. That was never safe.
checkConditional() sends the default on its 304, so revalidation reverts
it. The proxy can also attach the per-visitor cookie to a first visit's
API response, and a shared cache would replay it. Making public caching
work needs both changed together, so the docs now withdraw the invitation:
a route may tighten the default but not loosen it. Docs only.

Fixes #751

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tell the admin when an agent export left built-in agents out (t-879 review)

A mixed selection exported without its built-in agents and said nothing,
so an admin could believe every selected agent was in the file. The export
now sends the number it left out in X-Skipped-System-Agents, and the agents
table shows a notice when it is above zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Make the reopened PDF preview safe to use from the document list (t-880 review)

Three gaps the review found once Review could open the preview from the list:
- Two quick Review clicks could let the slower response replace the preview
  just opened, so an admin could confirm the wrong document. Every Review
  button is now disabled while one loads.
- Reopening the same document kept text edited and abandoned in the last
  session, and Confirm would send it. The modal now remounts on every open.
- Discard left the deleted row in the list. The modal gains an optional
  onDiscarded callback, and the list refreshes on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Validate the invitation delete address with the shared email schema (t-878 review)

The route had its own email schema that skipped trimming and lowercasing.
Every invitation is created through emailSchema, which lowercases, so a
delete for Alice@Example.com could never find the stored invitation.
Reusing emailSchema fixes that and keeps the rule in one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop stale Review responses, and clear the export notice with its selection (review round 2)

- Disabling the Review buttons did not stop a slow Review response from
  landing after an upload's preview opened, or after its row was deleted.
  A request counter shared by every way of opening the preview now drops
  any response a later open or a delete has overtaken. The buttons no
  longer need locking.
- Discard refreshes the embedding status as well as the list, as confirm
  and delete already do.
- The left-out notice on the agents table clears when the selection changes.
- The admin endpoint docs list the invitation delete's 400 and its
  lowercased lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… (#1051)

* Let operators set how long failed webhook deliveries are kept (t-884, #838)

webhookDlqRetentionDays was read by the retention prune but nothing could
write it globally: no field in the settings PATCH schema, no route branch,
no form control, and backups dropped it. Only an org's retention slice could
set it. Wire it through the settings schema, route, OrchestrationSettings,
the Retention card (blank = use the webhook window) and backup round-trip.
An older backup without the field imports it as null, as the other
later-added windows do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bound the backup's failed-webhook window and keep it on an older restore (t-884)

Review fixes. The backup schema accepted any number, so a backup with 0 or
a negative window would prune every dead-lettered delivery on the next tick;
it is now held to the PATCH bound (int, 1-365, or null). It is also no
longer defaulted to null: a backup taken before the field existed restores
without clearing a window set since. The hydrate and form types now require
the field, like their neighbours.

Not taken: a refine tying it to webhookRetentionDays (a shorter window for
failures is a legitimate choice), and a way to keep failures forever while
pruning the rest (what null means is unchanged, and documented as a gap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…un (t-843) (#1050)

* Keep a running workflow from writing an erased person back into its run (t-843)

Every engine write of a run (checkpoint, approval pause, finalise) now runs
in a transaction: the write locks the row, then the run checks for erasure
receipts since it started. When there are any, the trace, input, step
outputs and error message are redacted in memory by erasure's run rule and
the row is written again before commit. Erasure makes one more pass over
recently written runs after it commits, for a write that landed while its
transaction was open on a row its pass had not locked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test the run-write erasure check and the pass after the commit (t-843)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Smoke a running workflow against an erasure in Postgres (t-843)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Document what a running workflow's writes do after an erasure, and assert the whole trace in the sends smoke (t-843)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Record the running-workflow erasure fix in the CHANGELOG (t-843)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Allowlist the run-write receipt query and count the erasure's run pass after the commit in the user-deletion tests (t-843)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Lock every recent run in erasure's pass after the commit, and take its subject from the transaction (t-843, review round 1)

The pass after the commit narrowed by the person's text, which Postgres tests
against a row's committed version before it waits for the row's lock, so a
running workflow's write still open when the pass scanned was skipped. It now
locks every run written since or not finished. Its subject now comes from the
transaction, so a failed pre-pass lookup no longer skips it. An id that is
only a JSON key no longer counts as named, since redaction cannot remove it,
and the pause no longer redacts its trace entry twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Lock each run on its own in erasure's pass after the commit, and keep the terminal event and hook clear of the erased (t-843, review round 2)

The pass after the commit lists the runs still pending or running, or written
since, by id, then locks and redacts each in a short transaction of its own.
That waits for a running workflow's open write without holding a page of
locks, and leaves paused runs the transaction's pass already reached. The
completion event and the workflow.failed hook now carry what the final write
wrote. The follow-ups are filed as t-882 (supervisorReport, running-step turns)
and t-883 (approval, rejection and the scheduler's failure write).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Count the user-deletion transactions again now the pass after the commit opens one per run it finds (t-843)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep erasure's pass after the commit going past a run it cannot lock, and give it the engine's connection wait (t-843, review round 3)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ry tests (t-889) (#1054)

* Count only core's platform agents in the tenancy smoke and the reconcile test (t-889)

A fork that registers its own platform agents through lib/app/platform-agents.ts
failed two core checks that compared the live registry with core's counts.

- Smoke [11]: pin the 12/4 split on CORE_PLATFORM_AGENTS and require each core
  slug in the registry's list for its audience; count the install org's rows by
  registered slug, so a fork's own seeded isSystem agent is not counted (#973).
- Reconcile test: hold the fork seam empty, so the real-registry case measures
  core's registry and never runs a fork definition against the stand-in.

Closes #974.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say that core's platform-agent checks leave a fork's registrations green (t-889)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Let the smoke take a fork's replaced core agents, and require registered agents active (t-889 review)

- Require every core slug registered, whatever its audience, instead of
  pinning core's 12/4 split by audience: a fork may replace a core slug and
  move it, and the split is already pinned by the unit tests.
- Install org: every registered slug present AND active, one condition.
- Docs: name defaults.test.ts as the row a fork pins, and the core slugs the
  smoke drives by name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Hold the fork seam empty in the guard and clone tests, and drop a smoke check that could not fail (t-889 review round 2)

- platform-agent-guard.test.ts and agents.id.clone.test.ts read the real seam
  through isReservedAgentSlug, so a fork registering the docs' own example
  failed them: the same #974 defect, missed by a grep for two function names.
- Smoke [11]: the 'registry holds every core slug' check could never fail
  (nothing removes a slug); keep only the non-empty guard the absence checks
  need. Install org: presence by registered slug, without the isActive
  requirement round 1 added (a fork may ship a dormant agent).
- Docs: claim only what holds, and name what still notices.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Take the seam mock back out of the clone test: no fork registration fails it (t-889)

Measured: with a fork registering 'my-source-copy' the case moves past it and
passes with or without the mock, so the mock guarded nothing a fork would hit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say which core tests hold the platform-agent seam empty (t-889)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ewer Claude models from OpenRouter (t-890, t-891) (#1055)

* Stop sending temperature to Claude models that refuse it (t-890)

Claude Opus 4.7+, Sonnet 5+, Haiku 5+, Fable and Mythos 400 on any
sampling temperature. The Anthropic provider sent the agent's
temperature whenever thinking was off, so an agent on
claude-haiku-5-5 failed every call (#986).

acceptsSamplingTemperature() is an allow-list of the families that
still honour one (Claude 2/3.x, Haiku 4.x, Opus/Sonnet 4.0-4.6), so an
unrecognised model drops the temperature rather than failing. The
caller's value is still recorded on the trace's requestParams.

A family heuristic beside supportsReasoningEffort rather than a matrix
column: it needs no migration, and every Claude release since 4.6 has
gone the same way, so the default for an unknown id already covers the
next one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Price newer Claude models from OpenRouter under their API ids (t-891)

OpenRouter writes Anthropic versions with a dot (claude-haiku-5.5);
Anthropic's API, and so every agent, uses a dash (claude-haiku-5-5).
The registry filed OpenRouter's entry only under the dotted ids, so
turns on the newer models had no known price and cost caps couldn't
count them (#987).

refreshFromOpenRouter now also files each Anthropic entry under its
dashed API id. An entry OpenRouter lists under that exact id wins over
the alias. A matrix row on the same id is treated as for any model
OpenRouter lists: its descriptive fields stand and the published split
price wins (t-774).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say which Claude models get no temperature, and how OpenRouter's dotted ids are filed (t-890, t-891)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Give OpenRouter's Anthropic entries the API id as their own, and share the Claude id normaliser (t-890, t-891)

Code review, round 1. The alias overwrote the static fallback's dashed
entry with an object whose id was still dotted, so listings and the
discovery merge (keyed on id) lost the API id and offered one Anthropic
rejects. The entry now takes the dashed id as its own and keeps the
dotted one as an alias.

Bedrock cross-region ids (us./eu./global.anthropic.) never matched the
Claude checks, so an old model through such a proxy lost its
temperature. One normaliser now serves acceptsSamplingTemperature and
supportsReasoningEffort, in place of two copies of the prefix strip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave OpenRouter's Claude variants on their own ids (t-891)

Code review, round 2. anthropicApiId turned `claude-3.7-sonnet:thinking`
into `claude-3-7-sonnet:thinking`, an id Anthropic's API does not have,
and listed it under that name. A `:variant` id now keeps OpenRouter's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…use budgets (t-894) (#1056)

* Send adaptive thinking, not a token budget, to Claude models that refuse budgets (t-894)

An agent on Claude Opus 4.7+ or Sonnet 5+ with a reasoning effort set
failed every call: the Anthropic provider sent
thinking: { type: 'enabled', budget_tokens: N }, which those models
reject with a 400.

acceptsThinkingBudget() is an allow-list of the families that still
take a budget (Haiku 4.x, Opus/Sonnet 4.0-4.6), matching
acceptsSamplingTemperature, so an unrecognised model gets adaptive
thinking. On the rest, low/medium/high send thinking: { type: 'adaptive' }
and output_config.effort of the same name. minimal sends effort 'low'
with no thinking field: Opus 5.5 and Sonnet 5.5 400 on
{ type: 'disabled' }, and leaving thinking out runs Opus 4.7/4.8
without thinking while the always-on models run at their lowest
effort. Models that take budgets are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Narrow the reasoning effort through a local so the thinking branch type-checks (t-894)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Share the Claude 4.6 cutoff between the temperature and budget rules; document adaptive thinking on reasoningEffort (t-894 review)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…uest responds (t-846) (#1057)

* Keep a scheduled, recovered or resumed run going after the response (t-846)

The scheduler's four engine drains and the inbound and approval routes'
started runs were bare void promises. On a serverless host work left running
after the response can be frozen, stalling the run until recovery or the
reaper finds it. Each is now handed to Next's after() through
keepRunningAfterResponse, which falls back to running on the process where
there is no request scope (the dev ticker, scripts, tests).

The inbound and resuming approval routes declare maxDuration = 300, as the
maintenance tick does, so the drain gets the same budget on every plan.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Prove each route hands its started run to after() (t-846)

Inbound, chat/embed approve and admin approve: after() is handed a promise
that stays pending until the run or resume settles. The legacy token approve
route, which does not resume, hands it nothing. The helper logs a failure as
'<label> failed', keeping the admin approve route's existing message.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say that every engine drain started from a request outlives the response (t-846)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Let drainEngine keep itself alive, and hold a resume until its run settles (t-846)

Review round 1:
- drainEngine registers its own run with keepRunningAfterResponse, so a bare
  void drainEngine(...) is safe again and five hand-written wrappers go.
- resumeApprovedExecution awaits the drain, so the route's one hold covers
  the lookup and the run; its docstring no longer says fire-and-forget.
- The schedules tick route sets maxDuration = 300 like the other routes that
  start drains.
- No request scope is recognised by Next's error code E468 first, then by
  the message.
- The admin approve test asserts the handed promise stays pending until the
  resume settles.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say that drainEngine keeps itself alive, and what it does not hold yet (t-846)

The engine's detached webhooks, hook dispatch and lease events still end with
the drain's hold; filed as t-903.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Answer review round 2 (t-846)

- Route comments and docs say a cut-off run is recovered by the pending
  sweep or the orphan sweep, and that each orphan re-drive uses a recovery
  attempt.
- The maintenance tick hands its chain to keepRunningAfterResponse too, so a
  refusing after() logs rather than turning a committed tick into a 500.
- drainEngine forwards runDrain's parameters rather than restating them.
- Two doc sentences garbled in round 1 rewritten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the response (t-903) (#1059)

* Keep the webhooks, hooks and lease events a run detaches going after the response (t-903)

t-846 held each engine drain past the response, but not the work the drain
starts without awaiting. On a serverless host that work could be frozen once
the drain settled: an approver's approval_required notification, a crash
notification, hook deliveries, lease-inspector rows.

- dispatchWebhookEvent, emitHookEvent and recordLeaseEvent register their own
  work with keepRunningAfterResponse, so every caller that does not await them
  is covered, inside these modules and outside (streaming chat, the circuit
  breaker, escalation, the agents route).
- emitHookEvent's dispatch now settles once each hook's first attempt has, so
  its one hold covers the deliveries.
- The engine's approval_required IIFE, the scheduler's two execution_crashed
  IIFEs, the engine's parallel current-step writes and both manual retry
  attempts are wrapped in the helper.

In-process retry timers are left as they are: the tick's retry sweeps pick
them up if the process is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say that what a run detaches is held past the response too (t-903)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Answer review round 1 (t-903)

- logCost registers its own write too: the engine's llm-runner, agent-call
  and chat-turn steps, and every other caller, write it with void, and a lost
  cost row is spend the budget caps never see.
- The parallel batch's markCurrentStep goes back to void: the drain's own
  hold covers it while the batch runs.
- scheduling.md says the self-registering functions are held when called
  while a request or an enclosing after() task is open, not from a timer
  after everything has settled.

Declined, with reasons in the PR: a shared display-name dispatch helper (the
three payloads differ), skipping registration for awaited calls (negligible),
and not awaiting hook first attempts (that wait is the point, bounded by
DISPATCH_TIMEOUT_MS.hook).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Answer review round 2 (t-903)

- The workflow_failed webhook (engine/events.ts) and the budget_exceeded
  webhook (chat streaming handler) are wrapped where they start: both read
  display names before dispatchWebhookEvent registers its own hold.
- emitHookEvent's docblock says only first attempts are held, not retries.
- Tests prove the workflow_failed dispatch and both manual retry attempts
  stay held until they settle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ng when it needs longer (t-845) (#1061)

* Answer an account deletion within a request budget, and show it pending (t-845)

eraseUser() takes an optional deadline. It stops at the next safe point
once the deadline passes (avatars, each hook, each pre-pass chunk or page,
each transaction try) and throws ErasureDeferredError. Work already running
is capped at the time left through statement_timeout, lock_timeout and the
transaction timeout. Without a deadline nothing changes.

requestUserErasure() erases within ERASURE_REQUEST_BUDGET_MS (8 s,
overridable by env) and answers 'pending' when it runs out, leaving the next
attempt due at once. Both DELETE user routes answer 202. The retrier gives
each attempt ERASURE_RETRY_BUDGET_MS. The delete-account form shows the
deletion in progress, and the admin users table shows a 'Deletion pending'
badge with no delete action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the erasure request budget and the 202 pending answer (t-845)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the delete-account dialog open while the deletion runs (t-845)

The confirm button is Radix's Dialog.Close, so the dialog closed before
the API answered: a 202 never showed 'Deletion in progress', and an error
was set on a closed dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the erasure deadline, the 202 pending answer and its UI (t-845)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Allowlist the erasure deadline's set_config, and expect erasureRequestedAt on the users page (t-845)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show a 409 on delete-account as a deletion in progress, not an error (t-845)

The route signs the person out before answering 409, so leaving the error
in an open dialog stranded them on a page with a dead session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the retrier's erasure finishing: time for the transaction, real failures, the tick's limit (t-845)

Code-review findings:
- The retrier's pre-passes stop 60 s before its deadline (prePassDeadline)
  and hand the rest to the transaction, so an erasure whose scans outlast
  the budget no longer defers at the same scan every attempt.
- Only a timeout after the deadline is a deferral (isTimeoutError); any
  other error is a failure, logged and backed off.
- A deferral no longer counts toward erasureAttempts.
- An attempt that finds the account already erased answers with the
  receipt, deferral or not.
- The retrier ends by the tick's own limit (stopBy) and claims nothing
  with too little of it left, so the host never kills it holding its lease.
- boundLockWaits takes statementsToo in place of a second set_config helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the delete-account dialog open until the request answers (t-845)

Closed mid-request, a later 202 or 409 left the person signed out on the
settings page with no message and no redirect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Back off the retrier's deferrals, and hold the pass after the commit to the deadline (t-845)

Code-review round 2:
- A transaction timeout in the retrier, which had its 60 s set aside, is a
  failure, not a deferral.
- A retrier deferral is counted and backed off like a failure and logged
  as a warning; only a request's deferral is due at once and uncounted.
  A stuck erasure no longer retries every tick unseen.
- The run pass after the commit starts no run past the deadline, and caps
  each run's wait at the time left; what it leaves is logged as an error.
- TICK_MAX_DURATION_MS replaces the private tick-limit copy; the route
  test holds it equal to maxDuration.
- isTimeoutError reads Prisma's error meta through a Zod schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave the page once when Continue closes the pending-deletion dialog (t-845)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the deadline to the request: the retrier and the pass after the commit run to the end (t-845)

Code-review round 3 found the retrier's time limit was the source of most
round 1 and 2 findings: on a long-running host it could keep a heavy
erasure from ever finishing, and holding the run pass after the commit to
the deadline could skip it for good, with nothing to redo it.

- The retrier has no deadline again, as before t-845; a run killed by its
  host is retried when its lease runs out. prePassDeadline, stopBy,
  ERASURE_RETRY_BUDGET_MS, ERASURE_MIN_RETRY_MS, TICK_MAX_DURATION_MS and
  the deferred count are gone.
- The run pass after the commit runs in full again.
- A bounded transaction measures its statement cap once it has a
  connection, so the connection wait is not added on top; the same in
  waitForSubjectLocks.

Kept from earlier rounds: only a timeout is a deferral, an already-erased
account answers with its receipt, a deferral is uncounted, and the 409
and in-flight dialog handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…(t-904) (#1063)

* Take October's Dependabot bumps: Next 16.4, vitest 5.0.5, vite 8.3.5 and 43 more (t-904)

Folds the three open npm Dependabot PRs into one branch off current main,
because each rewrites package-lock.json and they overlap on vite:

- #996 production-minor-patch (30 bumps); next taken to 16.4.2 rather than
  16.4.0, to match eslint-config-next / @next/eslint-plugin-next 16.4.2
- #1040 development-minor-patch (16 bumps)
- #1060 vite 8.3.5, which supersedes #1040's 8.3.4

@react-email/ui 6.11.3 now pins next 16.4.1, outside the RCE range its
override was added for. The override stays, so the preview server keeps
sharing the app's next instead of installing a second copy; its reason is
restated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep Next's agent rules in a committed AGENTS.md, imported from CLAUDE.md (t-904)

Next 16.4 writes its agent-rules block only to AGENTS.md; 16.3 also kept it
in CLAUDE.md when it was already there. Without a committed AGENTS.md, the
first next dev run under an agent leaves an untracked file in every checkout
and every fork. AGENTS.md is generated by Next's own writer, so next dev
finds it current and leaves it alone (checked with a real next dev run).
CLAUDE.md's frozen copy is replaced with an @AGENTS.md import.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say when the @react-email/ui next override needs revisiting (t-904)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Bumps the github-actions group with 2 updates: [github/codeql-action](https://github.com/github/codeql-action) and [trufflesecurity/trufflehog](https://github.com/trufflesecurity/trufflehog).


Updates `github/codeql-action` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@v4.38.2...v4.38.3)

Updates `trufflesecurity/trufflehog` from 3.97.9 to 3.99.2
- [Release notes](https://github.com/trufflesecurity/trufflehog/releases)
- [Commits](trufflesecurity/trufflehog@v3.97.9...v3.99.2)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: trufflesecurity/trufflehog
  dependency-version: 3.99.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…katex alerts (t-914) (#1064)

* Take Prisma 7.10.1 and mermaid 12.2.0, clearing the deepmerge-ts and katex alerts (t-914)

Two of the four Dependabot alerts left open after #1063 have fixes inside
their parents' current majors, released after Dependabot's October run:

- deepmerge-ts (high, GHSA-ggr8-5vv4-36mx): Prisma 7.10.1 moves
  @prisma/config to deepmerge-ts 8.0.2. prisma, @prisma/client and
  @prisma/adapter-pg move together.
- katex (low, GHSA-238p-pmpm-9mq7): mermaid 12.2.0 depends on katex ^0.18.

Prisma 7.10.1 also pins mysql2 to 3.24.4, which meets the prisma override's
own removal condition (3.23.1 or later), so the override and its reason go.
mysql2 resolves to 3.24.4 rather than 3.24.5 as a result, still past both
advisories the override was added for.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tell forks the deepmerge-ts alert is fixed and the prisma override can go (t-914)

The 0.14.0 Security entry told forks to merge the prisma -> mysql2 override,
and the 0.13.0 notes said only Prisma 8 would fix deepmerge-ts; both are
answered in [Unreleased]. The security overview's override count was stale
(it named two of three), so it now points at overrideReasons instead of
restating a list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Cut Sunrise 0.15.0 (t-888)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Give the 0.15.0 notes the suite's measured size (t-888)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Correct the 0.15.0 notes against the code (t-888)

Review of the release blockquote against the migrations, code and git:
- eight migrations, not five, take locks that scale with table size: the
  audit-log support-grant indexes and FK, and the two indexes on user;
- delivery_destination_index builds a B-tree and a GIN index on one table;
- the delivery seed fills destination only where the parent still exists,
  and split prices reach only uncustomised seeded rows;
- a fork that replaced canAdminister must gate on scope.tier;
- <AdminTierGate> is new, so a fork's own admin layout must add it;
- the owner layer's scope and its silent findFirst narrowing;
- only a memberless user with no signup record loses the install-org heal;
- braces / micromatch / fast-glob are still in the tree and accepted;
- the Prisma 8 note was 0.12.0's, here and in the t-914 entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nsole, support access

Merges v0.15.0 (135 commits, v0.14.0..v0.15.0) in one stage. 34 conflicts,
resolved by taking upstream's change and keeping the fork's additions on top.

- RESPARKABLE_VERSION is 0.15.0.
- Platform jobs: upstream's new pendingErasures job sits after
  platformAgents; the fork's transferJobs and transferArchiveExpiry follow
  it, unchanged. The job tests, the tick route tests and scheduling.md list
  all three. The tick route test keeps the fork's count derived from
  PLATFORM_JOB_NAMES rather than upstream's literal 11.
- Sunrise asks taken upstream, so the fork's local copies give way to
  upstream's: the router-mock scanner (ask #52, sunrise#936) and the
  host-coupled heap assertions in chunked-lint.test.ts (ask #45,
  sunrise#702). The duplicate check:router-mocks script is dropped.
- The tenancy smoke's platform-agent count takes upstream's registered-slug
  check, which is the fork's #973 fix generalised.
- Admin layout takes upstream's tier gate and sidebar props on the fork's
  lattice-field background. AppHeader keeps the fork's sticky lattice panel
  and takes upstream's small-screen truncation.
- RouteErrorBoundary's reset is now retry: the /resparkable error page
  follows.
- Erasure doc: upstream's steps 5-8 come first, the fork's transfer-archive
  cleanup is step 9; transfer jobs stay in the cascade/SetNull table.
- CLAUDE.md: upstream's optional Hub section and @AGENTS.md ending, the
  fork's doc rows and .test troubleshooting kept. AGENTS.md committed.
- Lockfile seeded from v0.15.0's and reconciled with --package-lock-only:
  upstream plus the fork-only packages. No version floated.
- CHANGELOG: fork [Unreleased], then upstream's 0.15.0 verbatim.
- model-graph.generated.ts regenerated for the merged schema.

Not yet done: the owner layer's effect on the fork's reads (§115), routing
the fork's account deletion through requestUserErasure(), erasure-hook
idempotency, migrations, validate and the full test run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ransfer guards

- Owner layer (§115): TransferJob is personal (not enforced);
  ResparkableGroupMember and ResparkableGroupAuditEntry are not personal.
  Registered from lib/app/owner-classification.ts through
  lib/framework/resparkable/owner-classification.ts. The billing sweep's
  raw read of ai_workflow_execution goes in the raw-SQL manifest: it runs
  from the tick, where nobody is narrowed. The fork-tier test leaves
  TransferJob out of its core half, since the model lives in
  platform.prisma.
- Capability seed parity (#545, #646): the 24 capabilities are paired
  with their classes in lib/app/capability-seed-pairs.ts, derived from the
  dispatcher's registration list. Seeds 004 and 006 read capabilities,
  write none and throw by design when one is missing, so they are
  unchecked.
- Account transfer: User.signupOrgId and the five erasure columns are
  regenerated, never imported, so a bundle cannot move the importing
  account into another org or schedule its deletion. OrgSupportGrant is
  excluded; AiAdminAuditLog.supportOrgId is a dismissed soft reference.
- CUSTOMIZATION.md lists the fork's lib/app/robots.ts seam.
- The erasure-hook comments no longer say a boot registration may be
  missing: 0.15.0 keeps the registry on globalThis (sunrise#691).
- Each filled seam is pinned in defaults.test.ts under a FORK note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he CHANGELOG entry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core runs initAppErasureHooks() itself before it first reads the hook
registry, in whatever process is erasing somebody. Registering from
initResparkable() only covered a process that had booted the app, so a
script calling eraseUser() directly skipped the hook and left unaccepted
invites and stored document originals behind with no error.

Core's erasure tests assume the seam ships empty, so they mock it empty
under FORK notes; defaults.test.ts pins the real registration.

Also records ask #44 as landed (sunrise#691) and rewrites install.md
section 2.13, which still said the hook was gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Next 16.4.2 fails the Docker build resolving Google font files now that
Google serves them at extensionless URLs (vercel/next.js#100277).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JohnD-EE
JohnD-EE merged commit 694fe77 into main Oct 28, 2026
16 checks passed
@JohnD-EE
JohnD-EE deleted the chore/merge-upstream-sunrise-v0.15.0 branch October 28, 2026 08:49
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