Skip to content

feat(cloud): observability ingest pipeline — issues + incidents (Phase 3) - #140

Merged
prisis merged 3 commits into
claude/cloud-platform-dx-ojvkmufrom
feat/cloud-telemetry-ingest
Jul 12, 2026
Merged

feat(cloud): observability ingest pipeline — issues + incidents (Phase 3)#140
prisis merged 3 commits into
claude/cloud-platform-dx-ojvkmufrom
feat/cloud-telemetry-ingest

Conversation

@prisis

@prisis prisis commented Jul 11, 2026

Copy link
Copy Markdown
Member

Phase 3 — Cloud observability ingest + durable store + hosted views

Third phase of the observability plan (superlog → Lunora). Stacks on the cloud control plane (#85) — base is claude/cloud-platform-dx-ojvkmu, not alpha. Builds on Phase 1 (grouped Issues, #138) and Phase 2 (OTLP transport, #139) to give the hosted platform durable, cross-deployment monitoring across workers and containers.

What's here

  • Ingest — POST /v1/telemetry (src/deploy/router.ts + src/telemetry/otlp.ts): accepts OTLP-over-HTTP/JSON from the tenant otlpSink and the @lunora/container exporter (the Phase 2 wire contract), decodes the error spans, and folds them into grouped issues/incidents via a deploy-key-authorized telemetry.ingest mutation.
  • Durable store (apps/cloud/lunora/): issues + incidents .global() D1 tables, fingerprinted with @lunora/fingerprint — the same hash the local Studio computes, so a local Issue and a cloud Issue are one object. Container error events additionally open/update an incidents row (crash-loop / OOM). issues.ts + incidents.ts expose member-authorized list + setStatus.
  • TelemetryStore adapter (src/telemetry/store.ts): one interface, one Cloudflare-native impl — Analytics Engine metrics + a guarded Pipeline→R2 raw archive. Each method no-ops without its binding, so ingest works unchanged wherever the telemetry bindings aren't provisioned. A higher-fidelity backend (ClickHouse) can implement the same interface later.
  • Hosted views (src/client/): IssuesSection + IncidentsSection, gated behind the logStreams plan entitlement, wired into OrganizationDashboard (its tab render refactored to a SECTIONS map).
  • Bindings (wrangler.jsonc): a TELEMETRY AE dataset + a TELEMETRY_BUCKET R2 bucket.

Decisions that diverge from the plan (both simplify + de-risk)

  • Synchronous ingest, no queue. The cloud app has no queue producer binding and its only consumer is a tenant fan-out — so, like usage.ingest/logs.ingest, the mutation inserts to D1 directly (fingerprint + a handful of indexed upserts is cheap and the control-plane mutation is serialized). Metrics/raw archival are best-effort side-effects in the handler, never blocking or failing ingest.
  • Deploy-key auth, not the plaintext adminToken. Reuses authorizeDeployKey (hashed, unique by_hash index), sidestepping the plan's plaintext-token-as-bearer caveat entirely (deployments.adminToken has no by-token lookup index anyway).

Verification

  • apps/cloud full suite 166/166 pass (33 files), incl. a new 8-case telemetry.test.ts covering the OTLP decoder + the store.
  • tsc --noEmit clean and eslint 0/0 on every changed file.

Notes for review

  • Two commits: fix(cloud): …stripe subpath (a pre-existing build break — server.ts imported provider adapters from the @lunora/payment root, which intentionally doesn't re-export them; unrelated to Phase 3, fixed so this branch typechecks) and feat(cloud): …observability ingest (Phase 3).
  • Stacked dependency: the diff vendors packages/fingerprint/** (Phase 1's zero-dep package, not yet merged) so this compiles on the cloud branch. It folds away once Phase 1 (feat(observability): grouped error Issues (local, OSS) — Phase 1 #138) lands on alpha and the cloud branch rebases.
  • Deferred (deploy-time, not this PR): pointing tenant workers at /v1/telemetry — injecting LUNORA_OTLP_ENDPOINT + a deploy key at provisioning (src/provision.ts). And the raw Pipeline archive stays a no-op until a TELEMETRY_PIPELINE producer is minted per cell (wrangler pipelines create).
  • Privacy: issues/incidents store error.type + a sample message (may contain user input), same trust boundary as the Phase 2 sinks.

🤖 Generated with Claude Code

https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • main
  • alpha

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b833714b-976b-4673-a060-27bb54eb045a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cloud-telemetry-ingest

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for following the naming conventions! 🙏

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for confirming the Contributor License Agreement! 🙏

@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

React Doctor could not complete this scan.

react-doctor exited with status 0 before producing a JSON report.

Report this bug

Reviewed by React Doctor for commit 0ad7a0e.

prisis and others added 3 commits July 12, 2026 18:30
Phase 3 of the observability plan — durable, cross-deployment monitoring
in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport.

- ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant
  `otlpSink` and the container exporter, decodes the error spans
  (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents
  through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous
  — the cloud app has no queue producer binding, so ingest inserts to D1
  directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the
  plaintext admin token.
- store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with
  `@lunora/fingerprint` (the same hash the local Studio computes, so a local
  Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts`
  member-authorized read/triage functions. A `TelemetryStore` adapter
  (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a
  guarded Pipeline→R2 archive, each a no-op without its binding.
- dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the
  `logStreams` entitlement, wired into `OrganizationDashboard`.
- bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket.

Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on
the cloud branch; the graft folds away once Phase 1 lands on alpha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm
* feat(cloud): add observability alerts — rules, firing + delivery

Phase 4 of the observability plan (the "watches while you sleep" tier),
stacked on the Phase 3 ingest.

- schema: `alertRules` (name, target issue/incident, threshold, channel
  email/webhook, destination, enabled) + `alerts` (fired-alert audit trail
  with firing→delivered state, notification denormalized).
- firing: the telemetry `ingest` mutation loads the org's enabled rules and
  fires each the first time a source's count crosses its threshold
  (`before < threshold <= after`, so exactly once), inserting a `firing`
  alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts`
  (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`.
- delivery: the `/v1/telemetry` edge handler delivers fired alerts
  best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps
  them delivered — never blocking or failing ingest.
- functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list,
  markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered).
- dashboard: `AlertsSection` (manage rules + recent fired alerts), gated
  behind the `logStreams` entitlement, wired into `OrganizationDashboard`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* fix(cloud): validate webhook alert destinations against SSRF

An alert rule's webhook `destination` is `fetch`ed by the control plane
when the alert fires, so an owner/admin could otherwise aim it at internal
infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) —
server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https
only, public host, no embedded credentials, no loopback/private/link-local
IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in
`deliverAlert` (never fetch an unsafe target — defense in depth for any
rule created before this guard). String-level, so it can't defeat DNS
rebinding, but it blocks the direct-address cases. Unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Two SSRF gaps in the Observability alert delivery path:

- deliverAlert followed webhook redirects, so a destination that passes
  isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the
  metadata IP). Set redirect: "manual" and reject 3xx responses.
- isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which
  the URL parser compresses to ::ffff:7f00:1) and the unspecified
  address (::) through. Reject the whole ::-prefixed non-global class.

Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already
blocked via WHATWG URL normalization; added as regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj
@prisis
prisis force-pushed the feat/cloud-telemetry-ingest branch from ec37408 to 0ad7a0e Compare July 12, 2026 16:31
@prisis
prisis merged commit 3a3c715 into claude/cloud-platform-dx-ojvkmu Jul 12, 2026
4 of 5 checks passed
@prisis
prisis deleted the feat/cloud-telemetry-ingest branch July 12, 2026 16:58
prisis added a commit that referenced this pull request Jul 21, 2026
…e 3) (#140)

* feat(cloud): add the observability ingest pipeline (issues + incidents)

Phase 3 of the observability plan — durable, cross-deployment monitoring
in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport.

- ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant
  `otlpSink` and the container exporter, decodes the error spans
  (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents
  through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous
  — the cloud app has no queue producer binding, so ingest inserts to D1
  directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the
  plaintext admin token.
- store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with
  `@lunora/fingerprint` (the same hash the local Studio computes, so a local
  Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts`
  member-authorized read/triage functions. A `TelemetryStore` adapter
  (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a
  guarded Pipeline→R2 archive, each a no-op without its binding.
- dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the
  `logStreams` entitlement, wired into `OrganizationDashboard`.
- bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket.

Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on
the cloud branch; the graft folds away once Phase 1 lands on alpha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* feat(cloud): observability alerts — rules + delivery (Phase 4) (#141)

* feat(cloud): add observability alerts — rules, firing + delivery

Phase 4 of the observability plan (the "watches while you sleep" tier),
stacked on the Phase 3 ingest.

- schema: `alertRules` (name, target issue/incident, threshold, channel
  email/webhook, destination, enabled) + `alerts` (fired-alert audit trail
  with firing→delivered state, notification denormalized).
- firing: the telemetry `ingest` mutation loads the org's enabled rules and
  fires each the first time a source's count crosses its threshold
  (`before < threshold <= after`, so exactly once), inserting a `firing`
  alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts`
  (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`.
- delivery: the `/v1/telemetry` edge handler delivers fired alerts
  best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps
  them delivered — never blocking or failing ingest.
- functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list,
  markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered).
- dashboard: `AlertsSection` (manage rules + recent fired alerts), gated
  behind the `logStreams` entitlement, wired into `OrganizationDashboard`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* fix(cloud): validate webhook alert destinations against SSRF

An alert rule's webhook `destination` is `fetch`ed by the control plane
when the alert fires, so an owner/admin could otherwise aim it at internal
infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) —
server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https
only, public host, no embedded credentials, no loopback/private/link-local
IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in
`deliverAlert` (never fetch an unsafe target — defense in depth for any
rule created before this guard). String-level, so it can't defeat DNS
rebinding, but it blocks the direct-address cases. Unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cloud): harden webhook SSRF guard

Two SSRF gaps in the Observability alert delivery path:

- deliverAlert followed webhook redirects, so a destination that passes
  isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the
  metadata IP). Set redirect: "manual" and reject 3xx responses.
- isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which
  the URL parser compresses to ::ffff:7f00:1) and the unspecified
  address (::) through. Reject the whole ::-prefixed non-global class.

Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already
blocked via WHATWG URL normalization; added as regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
prisis added a commit that referenced this pull request Jul 22, 2026
* docs: add cirrus cloud platform plan

Reverses the managed-deploy-plane won't-do (VOID-TEARDOWN.md §0/§6,
CONVEX-PARITY.md #23) with a scoped managed tier: Workers for Platforms
data plane, Convex-shaped product model (teams/projects/prod+dev+preview
deployments), PartyKit-style managed-vs-BYO CLI split, and a phased
roadmap starting with remote-binding dev. Synthesized from a repo
inventory, a Convex Cloud teardown, and a GitHub/Cloudflare-primitives
survey.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: fold supabase platform teardown into cloud plan

Adds Supabase as a reference model: the OSS/proprietary cut line,
IS_PLATFORM single-codebase studio pattern, Branching 2.0 preview DX
(and its pain points to fix: empty branches, hourly branch billing
outside spend caps), the Management API + OAuth-apps growth channel,
and the structural cost advantage WfP gives over per-project VMs.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add wfp constraints, eject path, spike checklist

Gap review of the cloud plan: documents that cron triggers are
silently dropped for namespaced user Workers (with SchedulerDO
alarm-based fan-out mitigation), queue-consumer and send_email
verification items, KV account-limit multiplexing, EU jurisdiction
toggle, a cirrus-eject portability command built on existing
export/import RPCs, namespace-wide observability reuse, managed
backups + abuse controls in Phase 4, and a Phase 1 constraint spike.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: verify cloud plan claims against cloudflare docs

Fact-checked every Cloudflare claim in CLOUD-PLAN.md against the
official docs: WfP pricing, KV/D1/R2 account limits, DO/R2
jurisdictions vs D1 location hints, CF for SaaS hostname pricing, and
remote-bindings GA versions all confirmed. Adds three newly verified
constraints: no gradual deployments for user Workers (rollback must be
platform-side bundle re-upload), the 1200-req/5-min account API rate
limit on provisioning, and outbound-Worker TCP/DO interception
trade-offs.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add cell-based scaling architecture to cloud plan

Answers how the managed tier scales without hitting account limits or
risking platform-wide blocks: script-resident tenant state with lazy
inference-driven provisioning, multi-account cells with cell IDs baked
into identifiers from day one, a per-cell API token-bucket scheduler,
a tenancy graduation ladder up to managed-BYO and the Tenant API, and
abuse containment to keep tenant abuse from looking like platform
abuse.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: scope alchemy to cell bring-up, not tenant deploys

Records the provisioning-engine decision: the per-tenant deploy path
stays hand-rolled on cloudflare-typescript (control-plane DB as the
single source of truth, cell scheduler, progress events, rollback
artifacts); Alchemy (pre-1.0, v2 rewrite underway, no confirmed
dispatch-namespace resource) is a candidate only for low-cardinality
cell bring-up IaC, with Terraform/Pulumi as the fallback.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: correct alchemy facts (dispatch-namespace resource, v0.93)

Re-checked Alchemy against GitHub/npm: it is v0.93.12 (Apache-2.0) and
does ship a dispatch-namespace (Workers for Platforms) resource — my
earlier 'lacks a confirmed dispatch-namespace resource' was wrong and
'v0.9x' undersold it. Recommendation is unchanged (hand-roll the
per-tenant deploy, use Alchemy for cell bring-up) but now rests on the
real reason — source-of-truth shape and deploy-orchestration concerns,
not capability — with a re-evaluation trigger at a stable 1.x.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: adopt alchemy as the provisioning engine

Decision: Alchemy is the provisioning engine across cell bring-up,
per-tenant managed deploy, and BYO. Verified it ships DispatchNamespace/
Worker/D1/R2/DO resources plus a built-in D1StateStore and runs inside a
Worker (await alchemy(scope) -> finalize/destroy). Backing each tenant
scope with the control-plane D1 collapses the two-sources-of-truth
concern into one store. The per-cell rate-limit scheduler now paces
finalize() runs; bundling stays in the Vite pipeline; rollback re-
converges to a prior R2-retained bundle. Risk of a 0.x dependency
contained behind a @cirrus/provision adapter with cloudflare-typescript
as fallback; spike open items recorded.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: build on alchemy v2 (alchemy@next, 2.0.0-beta.55)

Per decision to start on the v2 line: target alchemy@next (verified
2.0.0-beta.55, Effect-based) to avoid a v1->v2 migration mid-build,
with v1 0.93.x as the named fallback. Records the trade-offs (beta
churn, Effect pulled into the control-plane tree, quarantined behind
the @cirrus/provision adapter) and turns the unverified v2 facts
(DispatchNamespace resource + D1/DO state store, confirmed on v1 only
since docs/CDN were unreachable) into hard Phase 1 spike gates with
v1 fallback per surface.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: commit fully to alchemy v2, drop v1 fallback

Remove the v1 (0.93.x) fallback hedging throughout: v2 (alchemy@next,
2.0.0-beta.55) is the engine outright. Spike gates remain but now
resolve via owned shims or upstream contributions rather than retreat
to v1; a hard unresolvable 'no' escalates the engine decision instead
of silently dual-tracking.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add forgotten must-haves + fleet runtime-versioning risk

Gap pass on the cloud plan. New risk #8 (the load-bearing one): the
Cirrus runtime is bundled into each tenant Worker, so a security patch
means redeploying the whole fleet unless the tenant Worker is made
'thin' against a central runtime — a fat-vs-thin decision that must be
made before Phase 1 since it shapes the bundle format, deploy API, and
vite emit. New section 7 collects launch-blocking gaps the plan had
assumed away: control-plane DB durability/DR, cross-cell disaster
recovery, secrets-at-rest + cell-token custody, frontend-hosting scope,
AUP + bill-shock/cryptomining controls, billing/MoR/tax, GDPR
processor/DPA/SOC2, account offboarding + right-to-erasure, platform
self-observability + status page, dispatcher canary, and a staging cell.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): scaffold control-plane app built on cirrus

First implementation step from CLOUD-PLAN.md: a new apps/cloud workspace
app that dogfoods Cirrus as the platform's own control-plane backend.

- cirrus/schema.ts: control-plane data model (cells, organizations,
  members, projects, deployments, deployKeys, auditLog), all .global()
  (D1) — the plan's 'Worker + D1' control plane.
- cirrus functions: organizations/projects/deployments/cells/deploy-keys
  (create/list/issue/updateStatus), with owner seeding + audit trail.
- src/server.ts: control-plane Worker entry wiring D1-backed global tables.
- src/provision.ts: the @cirrus/provision seam — the sole coupling to the
  Alchemy v2 engine (stub that rejects until the Phase 1 spike wires it).
- configs (package.json/tsconfig/project.json/wrangler.jsonc/eslint/vitest),
  README, and a provision test.

Verified: codegen clean (no advisories), eslint clean, tsc --noEmit
passes, vitest green.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add deploy-orchestration core

Builds the next control-plane layer on the scaffold's Provisioner seam,
all pure/testable (no live Cloudflare needed):

- token-bucket.ts: per-cell API budget (§2.5), models CF's 1200/5min
  account limit; deterministic + clock-injectable.
- scheduler.ts: CellScheduler paces/serializes provisioner work against
  the bucket with priority ordering + a concurrency cap.
- orchestrator.ts: runDeployment state machine emitting queued →
  provisioning → live/failed progress events (§2.2); destroyDeployment
  for preview-TTL/project teardown.
- keys.ts: deploy-key format/parse/hash helpers; deploy-keys.ts mutation
  refactored to use them (one tested place for the format + SHA-256).

17 tests across 5 files; eslint + tsc --noEmit clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add org authorization + deploy-key lifecycle

Closes gaps found reviewing the control plane:

- authz.ts: assertMember(ctx, orgId, roles?) — the org ACL gate. Every
  org-scoped function now verifies the caller is a member with a
  permitted role, closing an IDOR hole where any signed-in user could
  read/mutate any org by passing its id. Applied across projects,
  deployments, deploy-keys.
- members.ts: list / add / remove so memberships can actually be granted
  (owner is seeded on org create; admins/owners manage the rest).
- deploy-keys: verify (the deploy API's auth path — match by SHA-256,
  reject revoked, bump lastUsedAt, return the DB-authoritative target)
  and revoke (leaked-key mitigation); lastUsedAt/revokedAt are now live.
- deployments: create checks the project belongs to the org; updateStatus
  loads the deployment and gates on its org (documented as the system
  seam for the orchestrator).

codegen clean, eslint + tsc --noEmit clean, 17 tests pass.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): deploy API endpoint, deploy-key auth, handler tests

Lands the three remaining pieces together:

- Deploy API: POST /v1/deploy mounted via the httpRouter seam (src/deploy/
  router.ts) → pure handler (src/deploy/handler.ts) authenticates the bearer
  deploy key, records a queued deployment, drives runDeployment through the
  per-cell scheduler, and streams NDJSON progress (accepted→queued→
  provisioning→live/failed→done), patching status per phase.
- Auth path: investigation showed internalMutation is unreachable from the
  HTTP action-context dispatch (no system flag → RPC 404), so verify/
  updateStatus stay public; instead added deploy-key authorization
  (authz.authorizeDeployKey) and a dual-path (member session OR deploy key)
  on deployments.create/updateStatus, so CI deploys need no user session.
  Corrected the stale 'should become internalMutation' comments.
- Tests: handleDeployRequest (401/403/400 + success and failure streaming +
  status transitions) and authz (assertMember + authorizeDeployKey) via a
  fake ctx. 28 tests / 7 files; eslint + tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): refresh status — deploy API + auth now in place

* chore(cloud): track generated schema snapshot

Matches the apps/playground convention — .cirrus-schema.json is the
codegen schema snapshot used for migration/drift detection and is
committed, not gitignored.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): preview lifecycle, GitHub webhook, deploy client (Phase 1/2)

Phase 2:
- Preview deployments are TTL'd: deployments.create stamps expiresAt for
  kind=preview (src/deploy/preview.ts: deterministic previewScriptName +
  5-day previewExpiry); an hourly cron (cirrus/crons.ts -> internal
  deployments.cleanupExpiredPreviews) marks expired previews destroyed.
  Worker gains scheduled(); wrangler cron trigger added.
- GitHub webhook (src/github/webhook.ts): HMAC-SHA256 verify + pull_request
  -> preview-intent parsing, mounted at POST /v1/github/webhook.

Phase 1:
- Deploy client (src/deploy/client.ts): the cirrus-deploy core — POSTs to
  /v1/deploy and consumes the NDJSON progress stream.

13 new tests (41 total); codegen/eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: update roadmap — Phase 0 shipped, Phases 1-2 status

Phase 0 (remote-binding dev) is already implemented in the framework
(@cirrus/config remote-bindings + @cirrus/vite plugin + cirrus dev;
30 tests) — corrected from 'not started'. Phases 1-2 marked
substantially-built with the live-Cloudflare-dependent remainder
(Alchemy provisioner, dispatcher, e2e validation) called out.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): team invitations + billing quotas; repo-extraction guide (Phase 3/4)

Phase 3 (hosted studio sliver):
- Team invitations (cirrus/invitations.ts + invitations table): invite/list/
  revoke/accept, single-use SHA-256-hashed tokens (plaintext mailed once),
  owner-admin gated; accept-by-token adds the caller as a member.

Phase 4 (billing sliver):
- Plans + quota entitlements (src/billing/plans.ts) on @cirrus/payment's
  entitlements model — free/pro/enterprise limits + feature flags, with
  effectiveLimit/withinQuota and a free-tier fallback for non-subscribers.

Portability (move to a private repo):
- EXTRACT.md documents the mechanical extraction; audit confirms the app
  imports only published @cirrus/* entry points (no monorepo-internal reaches).

6 new tests (47 total); codegen/eslint/tsc clean; secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): real Cloudflare provisioner, quota enforcement, webhook project resolution

Provisioner (Phase 1 — the big one): replaces the rejecting stub with a real
implementation over a typed Cloudflare REST port (src/cloudflare/api.ts):
deploy provisions per-tenant D1/R2, uploads the user Worker into the dispatch
namespace with binding + DO-migration metadata, applies secrets, returns the
bundle hash + routed URL; destroy deletes the script. Port-injected so it's
tested with a fake; plug in CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN to run.
(REST via fetch rather than the unverifiable alchemy@next beta — same seam.)

Quota (Phase 4): plans.ts gains planLimit/withinPlanQuota; projects.create and
members.add enforce the org plan's limits.

Preview automation (Phase 2): projects gain githubRepo + byGithubRepo lookup;
the webhook resolves the connected project and returns the preview script name.

Env documented (.dev.vars.example + wrangler vars). 50 tests; eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): dispatcher Worker + hosted-studio admin-RPC proxy (Phase 1/3)

Phase 1 — dispatcher Worker (the request-path front door): resolveTenant
maps {scriptName}.{appDomain} (and custom domains via injected lookup) to a
dispatch-namespace script; the worker forwards via env.DISPATCHER.get with
per-plan limits. Separate deployable (dispatcher.wrangler.jsonc).

Phase 3 — admin-RPC proxy: proxyAdminRequest authorizes org membership,
forwards the admin RPC to the tenant's /_cirrus/admin/* with that
deployment's admin token, and records an audit entry. Pure (deps injected).

7 new tests (57 total); eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): cirrus login/link/deploy CLI commands (Phase 1)

Pure command logic over a ConfigStore + the deploy client: login persists the
API endpoint + deploy key, link binds a project, deploy streams a managed
deploy (requires login+link). File-backed store at ~/.cirrus/cloud.json for
the Node CLI; cerebro registration in @cirrus/cli calls these.

3 new tests (60 total); eslint/tsc clean; secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: roadmap status — control-plane backend feature-complete as code

All phases' backend code is built + unit-tested in apps/cloud (60 tests):
real REST provisioner, dispatcher, CLI, preview lifecycle, GitHub webhook,
team invitations, admin-RPC proxy, quota enforcement. Remaining items are
the ones needing live Cloudflare / external services / the studio UI.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): admin-proxy live wiring, usage metering, custom-hostname port (Phase 3/4)

Phase 3 — admin proxy mounted at POST /v1/admin: deployments now carry the
platform-minted tenant adminToken (set as the worker's CIRRUS_ADMIN_TOKEN
secret + stored on the row), deployments.adminTarget resolves {url, adminToken}
after asserting membership, and the router forwards to the tenant's
/_cirrus/admin/* with an audit-log.record entry.

Phase 4 — usage metering: usageEvents table + internal record mutation +
member summary query over a pure aggregateUsage roll-up. Custom hostnames:
CloudflareApi.createCustomHostname (Cloudflare for SaaS, zone-scoped REST).

Router refactored into per-route handlers. 64 tests; eslint/tsc/secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: roadmap — admin proxy mounted, usage metering + custom-hostname port added

* feat(cloud): add hosted studio react spa

Build the hosted-studio frontend for the Cirrus Cloud control plane: a
better-auth-gated React SPA served on one origin with the control-plane
Worker via @cirrus/vite.

- src/client: main/auth-client/Login, App auth gate, OrganizationList,
  OrganizationDashboard with tabs for projects, deployments, members,
  deploy keys, invitations, and usage; AsyncList loading/empty helper.
- Wire @cirrus/auth into src/server.ts (createAuth + cirrusD1Adapter,
  ensureMigrated, handleAuthRequest, authAdmin, resolveIdentity) and add
  AUTH_SECRET/AUTH_URL env + .dev.vars.example entries.
- Switch package scripts to vite (build/dev), add react/react-dom +
  @cirrus/react/@cirrus/auth deps, vite.config.ts, index.html, and the
  DOM lib + jsx in tsconfig.
- eslint: client section (filename-case, react-perf, void), browser
  globals; ignore vite.config.ts.
- Refresh README + CLOUD-PLAN status to reflect the studio UI.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add billing, metering, and hardened auth

Billing on @cirrus/payment (§4): org id is the payment referenceId. Wire a
Stripe adapter into createShardDO({ payment }); add cirrus/billing.ts with
checkout/portal actions, entitlements/subscription reads (resolved through
CIRRUS_CLOUD_PLANS with a free-tier fallback), and a signature-verified
processWebhook mounted at POST /v1/billing/webhook. The studio gains a
Billing tab.

Platform metering (§4): rename the resource-metering table to platformUsage
(freeing usageEvents for @cirrus/payment's billing ledger), add a deploy-key
authenticated usage.ingest mutation + POST /v1/usage endpoint, and enforce
per-plan runtime limits in the dispatcher (limitsForPlan → DISPATCHER.get).

Auth hardening (§3) on @cirrus/auth/better-auth: mail-backed email
verification + password reset (@cirrus/mail), optional GitHub/Google OAuth,
admin/twoFactor/passkey plugins, built-in auth rate limiting, plus a per-IP
@cirrus/ratelimit cap on the /v1/* surface. Invitations now email the token
via POST /v1/invitations/send (never shown in the browser). The Cirrus
organizations/members model stays the single org source of truth (better-auth
organization plugin deliberately omitted).

Add deps (@cirrus/mail, @cirrus/ratelimit, stripe), tests for the router
routes + rate limiting + per-plan limits (69 total), and reconcile the README
+ CLOUD-PLAN status (the provisioner is a real Cloudflare REST impl, not a
stub).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): enforce entitlements, wire metering source, add secrets

Close the billing loose ends and add the metering source, tenant secrets,
and an audit-log view.

Entitlements (close loose end #1): quota is now enforced against live
subscription state (cirrus/entitlements.ts resolves from the synced
`subscriptions` table) rather than the static organizations.plan column —
projects/members creation call assertWithinQuota, so a Stripe upgrade raises
limits immediately with no column to sync.

Per-plan dispatch limits (close loose end #2): deployments.planForScript +
a bearer-gated GET /v1/tenants/plan endpoint + a cached plan resolver in the
dispatcher (createPlanResolver) wire resolvePlan, so runtime limits actually
scale per plan instead of always falling back to free.

Metering source: the dispatcher emits one Analytics Engine data point per
tenant request (src/metering/analytics.ts); a reader port + HTTP impl and an
hourly usage.rollup compaction cron complete the pipeline alongside the
existing /v1/usage ledger ingest.

Tenant secrets (§7): AES-256-GCM envelope encryption at the edge
(src/secrets/crypto.ts), a secrets table (ciphertext + IV only), store/list/
listEncrypted/remove functions, POST /v1/secrets, deploy-time materialization
into the tenant Worker, and a studio Secrets tab.

Studio: add Secrets + Activity (audit log) tabs; add audit-log.list.

Tests: crypto round-trip, plan resolver caching/fallback, entitlement quota,
analytics writer/reader (83 total). Docs + .dev.vars.example updated.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): address review findings (rollup atomicity, deploy failure, dedup)

Apply /review findings on the recent billing/metering/secrets work:

Correctness:
- usage.rollup: the D1 global backend has no multi-statement transaction, so
  the old insert-summed-then-delete-originals order could double-count (over-
  bill) on a mid-rollup crash. Reorder to delete the extras first, then patch
  the surviving row's total last — a crash can now only under-count, never
  leave a summed row beside surviving originals.
- deploy handler: a tenant-secret decrypt failure (corrupt secret / rotated
  key) threw inside the NDJSON stream and left the deployment stuck in
  `accepted`. Catch it and transition to `failed` with a status update.
- POST /v1/secrets: encryption/config failures (e.g. a malformed
  SECRET_ENCRYPTION_KEY) now return 500, not a misleading 403 (kept distinct
  from the membership 403 the store mutation raises); reject the reserved
  CIRRUS_ADMIN_TOKEN secret name up front instead of silently clobbering it.
- studio: drop the plan picker from org creation — limits now come from live
  subscription entitlements, so selecting a paid plan at create-time granted
  nothing. Orgs start free; upgrade via the Billing tab.

Cleanup:
- Extract the cross-org IDOR guard into authz.assertRowInOrg and call it from
  secrets/members/deploy-keys/invitations (was four byte-identical copies).
- Remove dead plans.ts exports planLimit/withinPlanQuota (superseded by
  entitlements-based quota); add a single highestPlan/PLAN_PRECEDENCE helper
  and use it in deployments.planForScript (was a hand-rolled tier ladder).
- Memoize the Stripe payment config per isolate (was rebuilt on every shard
  request that touches ctx.payments).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* test(cloud): validate websockets through dispatch (phase 1 spike)

Validate the hottest path — hibernated-WS subscriptions + per-invocation
limits through env.DISPATCHER.get() — the least-documented WfP case (risk #3).

- spikes/ws-dispatch/: a runnable harness for live validation on a real
  dispatch namespace. A framework-free hibernatable-WebSocket Durable Object
  (the exact primitive ShardDO uses: acceptWebSocket + webSocketMessage),
  deployable into the namespace, plus a zero-dep Node probe that drives it
  through the dispatcher and asserts: (1) the WS upgrade survives the dispatch
  hop (101 + live socket), (2) a hibernated server push (broadcast) reaches the
  socket — the mutation-to-subscription shape, (3) cpuMs-limit behaviour. The
  README documents deploy/run, pass/fail, and the expected results + caveats.
- __tests__/dispatcher-ws.test.ts: unit-pins the dispatcher forwarding
  contract (returns the tenant 101+webSocket response unchanged, applies
  per-plan limits, meters the upgrade once) — runs in CI, no infra needed.
- dispatcher worker: clarifying comments on WS pass-through + per-frame
  metering semantics. CLOUD-PLAN risk #3 now references the harness.

The dispatcher half is verified here (94 tests); the end-to-end behaviour
needs a live Cloudflare account + the Workers-for-Platforms add-on to run.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant cron fan-out through dispatch (wfp workaround)

Cloudflare drops triggers.crons for Workers in a dispatch namespace, so tenant
cron jobs never fire. Fan them out from the control plane (CLOUD-PLAN §2.4).

- @cirrus/runtime: add an admin-gated POST /_cirrus/scheduled tick endpoint
  that runs a cron expression's jobs through the SAME handleScheduled path the
  native scheduled() trigger uses (user crons + code crons + backup), so a
  platform can drive a namespaced tenant's crons over HTTP. (Dispatch stubs
  expose only fetch()/connect() — no scheduled()/queue() — so HTTP is the only
  transport in.)
- src/fanout/cron.ts: pure 5-field cron-expression matching (lists, ranges,
  steps, dom/dow OR semantics) + dueTicks + fanOutCron orchestration.
- control plane: capture each tenant's cronSpecs on the deployments row at
  deploy; an every-minute heartbeat cron (cirrus/fanout.ts) makes codegen emit
  the */1 trigger, and server.ts scheduled() reads live cron targets and ticks
  each due tenant via env.DISPATCHER.get(script).fetch('/_cirrus/scheduled')
  with the per-deployment admin token (kept in-process — never exposed). Adds
  the DISPATCHER binding to the control-plane wrangler.

Tests: cron matching, dueTicks, fanOutCron (103 cloud tests; 337 runtime
tests still green). Live validation on a dispatch namespace pending; queue
consumer fan-out is the remaining half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant queue-consumer fan-out through dispatch (wfp workaround)

WfP namespaced Workers can't be queue consumers, so tenant queue-backed work
(@cirrus/mail sends, scheduler queue-workpool) never drains. Fan it out from a
platform-owned consumer (CLOUD-PLAN §2.4) — the queue counterpart to the cron
fan-out.

- @cirrus/runtime: add a `queueHandler` option + an admin-gated
  POST /_cirrus/queue endpoint that reconstructs the batch and invokes it,
  returning the message ids to retry. (Dispatch stubs are fetch-only, so HTTP
  is the only transport into a namespaced tenant.)
- src/fanout/queue.ts: pure grouping of a shared-queue batch by the producing
  tenant's script (envelope `{ script, body }`) + fan-out orchestration that
  collects per-message retries and retries a whole group on delivery failure.
- control plane: the account-level Worker is the consumer — server.ts queue()
  drains the shared cirrus-tenant-queue, resolves each tenant's admin token
  in-process (never exposed), forwards sub-batches via
  env.DISPATCHER.get(script).fetch('/_cirrus/queue'), and acks/retries per the
  tenant reply. Adds the queues.consumers binding to the control-plane wrangler.

Tests: groupByTenant + fanOutQueue (108 cloud tests; 337 runtime tests green).
Live validation on a dispatch namespace + a producer-side script-tagging helper
remain.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): align with the lunora rebrand + reuse @lunora/analytics

Rebased onto alpha, which renamed the framework cirrus → lunora. Reconcile the
control-plane app and reuse a newly-shipped package.

Rebrand:
- npm scope @cirrus/* → @lunora/* across deps + imports.
- app functions dir cirrus/ → lunora/ (+ tsconfig/eslint globs, _generated
  paths, the committed schema snapshot → .lunora-schema.json).
- reserved paths /_cirrus/* → /_lunora/* (incl. the new scheduled/queue tick
  endpoints) and the runtime-injected env.__lunoraCtx; renamed exported symbols
  (LunoraError, LunoraClient/Provider, useLunora, lunoraD1Adapter, LunoraAuth*,
  LUNORA_CRONS/FUNCTIONS); vite plugin cirrus() → lunora(); CLI config dir
  ~/.cirrus → ~/.lunora.
- wire the new required GlobalIntrospector.facetColumn via @lunora/d1's
  facetGlobalColumn.

Reuse:
- src/metering/analytics.ts is now a thin domain layer over @lunora/analytics
  (createAnalytics writeDataPoint + createAnalyticsSqlClient AE-SQL reader)
  instead of a hand-rolled writeDataPoint + HTTP SQL client.

Verified the rest is genuinely cloud-specific (cron-expression matching, AES-GCM
secret crypto, the Cloudflare REST provisioner, the per-cell CF-API token
bucket) — no upstream equivalent to fold into.

103→108 cloud tests green; runtime 379 tests green; tsc/eslint/build clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): rebrand the product Cirrus Cloud → Lunora Cloud

Complete the lunora rebrand to the product layer (the framework already moved):

- brand prose Cirrus Cloud → Lunora Cloud across code comments, README,
  EXTRACT, the studio (index.html title, Login/dashboard), and CLOUD-PLAN.md.
- env vars CIRRUS_* → LUNORA_*: LUNORA_ADMIN_TOKEN and LUNORA_MAIL_CAPTURE are
  functional (read by @lunora/mail); LUNORA_APP_DOMAIN / LUNORA_CELL and the
  VITE_LUNORA_URL client var follow for consistency.
- the LUNORA_CLOUD_PLANS entitlements constant.
- infra names cirrus-* → lunora-*: worker names (lunora-cloud, lunora-dispatcher),
  dispatch namespace (lunora-production), shared queue (lunora-tenant-queue),
  AE dataset (lunora_tenant_usage), the lunora.app apex, and the deploy
  dispatch-namespace prefix.
- the hosted-CLI verbs (lunora login/link/deploy) and config dir ~/.lunora.
- docs' reserved-path/marker refs (/_lunora/*, __lunora_admin__, env.__lunoraCtx).

108 cloud tests green; tsc/eslint/build clean; zero residual `cirrus` references.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* refactor(cloud): migrate functions to fluent builder api

Adapt the cloud control-plane functions to alpha v1.0.0-alpha.1's fluent
function builders: kind.input({...}).<terminal>(({ ctx, args }) => ...)
replaces the removed object form kind({ args, handler }). Regenerate
_generated/* and pick up codegen's observability block in wrangler.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): license under polyform noncommercial

The control plane is the proprietary product layer, so it must not carry
the framework's FSL-1.1-Apache-2.0 (which grants broad commercial rights).
Apply PolyForm Noncommercial 1.0.0: any noncommercial purpose is permitted,
but commercial use requires a separate license. Replaces UNLICENSED.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): adopt @lunora/bindings/analytics after package fold-in

The latest alpha folded @lunora/analytics into @lunora/bindings (subpath
export ./analytics, identical API) and codegen now emits
_generated/functions.ts importing @lunora/values directly. Swap the
dependency and import specifiers, declare @lunora/values, regenerate
_generated/*, and reconcile the lockfile.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): address pr review findings

- secrets: assert the project belongs to the org in store/list/
  listEncrypted and scope queries by organizationId, closing the
  cross-org IDOR where a member of one org could read or overwrite
  another org's project secrets (+ idor tests)
- deploy: require a base64 worker bundle in POST /v1/deploy and thread
  it client → CLI → provisioner instead of uploading an empty module;
  400 on missing/malformed bundle
- studio: replace try/finally + throw-in-try with promise combinators
  in Login/Invitations/Secrets forms so React Compiler can memoize them

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): add consolidated gap analysis and build plan

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): blue/green releases with health gating and rollback

Every deployment now uploads an immutable versioned script
({alias}-v{n}); the project's stable URL follows an active-deployment
pointer that only swaps after the new script passes a health probe, so
a bad deploy never replaces a serving one (gaps.md a1). Adds
POST /v1/deployments/rollback + lunora rollback (pointer swap back to a
retained superseded release), GET /v1/tenants/route + a cached alias
resolver in the dispatcher, per-phase deployment timestamps (a2), and
an x-lunora-id debug header (b3).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): aggregate spend caps with org suspension

Per-invocation limits cap one request; nothing capped aggregate period
spend (gaps.md c1). Adds a pure spend evaluator at the wfp cost basis
with per-plan default caps (org-overridable; explicit 0 = uncapped), an
hourly enforcement cron that suspends breaching orgs and self-heals
recovered ones, and dispatcher enforcement — a suspended org's tenants
serve 503 via the sentinel plan carried through the existing cache.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): custom domains — model, txt verification, hostname routing

First slice of gaps.md b1: the domains table (unique hostname, per-org
project scoping, redirect-only rows, cloudflare custom-hostname id),
add/list/remove/markVerified functions with the same authz gates as
secrets, a pure dns-over-https verification core (_lunora txt token +
platform cname check, injectable resolver), and routeForHostname — the
dispatcher-facing lookup that only ever routes verified domains to the
project's active script. Edge routes + dispatcher wiring land next.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): wire custom domains through edge and dispatcher

Completes the code-tractable half of gaps.md b1: POST /v1/domains (add,
returns the txt record to create), POST /v1/domains/verify (dns-over-
https txt + cname checks under the caller's session, outcome recorded
via markVerified), GET /v1/tenants/custom-domain for the dispatcher,
and a cached custom-domain resolver in the dispatcher that routes
verified hostnames to the project's active script and answers
redirect-only rows directly. cloudflare-for-saas cert provisioning
remains the 🌐 half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* style(cloud): hoist the trailing-dot regex to module scope

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): server-side builds, build logs, and push-to-deploy webhook

gaps.md a3/a4: builds table with a stale-recoverable work lease and
commit-sha dedup (a repeat push reuses the successful build's bundle
hash instead of rebuilding), streamed line-per-row build logs with a
cursor-paginated tail query, github app installations linked by account
slug, push + installation webhook parsing (default-branch pushes only,
zero-sha deletes ignored) wired through the hmac-verified edge route,
and a pure build-runner orchestration (claim → fetch → execute →
complete/fail) whose tarball-fetch and container-execute ports are the
remaining 🌐 half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant log ingestion and org right-to-erasure

gaps.md b2 + d3. logs: a tenantLogs ledger fed by the tail worker via
deploy-key-gated POST /v1/logs/ingest (batch + line-length caps, lines
truncated rather than dropped), a cursor-paginated member tail query,
and a 6-hourly retention prune (48h window). erasure: owners request
org deletion (30-day reversible window); the purge cron then erases
every org-scoped control-plane row, marks deployments destroyed for
the provisioner teardown path, and removes the org.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): mark shipped gaps in the build plan

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): dunning state machine and residency-aware cell placement

gaps.md c2 + f. dunning: a pure evaluator (payment failure → 14-day
grace anchored at first failure → suspend; any active/trialing
subscription rescues) driven by a 6-hourly cron over the synced
subscription states. suspensions now carry a reason so the spend-cap
and dunning crons only lift their own. placement: organizations.create
accepts a jurisdiction ("eu"/"fedramp") and picks a matching active
cell when no explicit cellId is given.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): lunora eject core — the no-lock-in exit hatch

gaps.md d2: a pure eject flow that pulls the tenant's full data
snapshot through its admin export api, scaffolds the byo wrangler.jsonc
the project would have had outside the platform (do bindings, d1
placeholder, sqlite migrations), and writes a restore readme — all over
injected ports so the packaging is fully unit-tested; the cli wires the
real i/o.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* style(cloud): use a template literal in the eject scaffold

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): studio tabs for domains, builds, and runtime logs

wires the round-7 backends into the hosted studio: a domains tab (add →
txt record callout → verify → live verified badge, remove), a builds
tab (per-project build list with live streamed output), and a logs tab
(deployment picker over a live runtime-log tail). marks c2/d2/f shipped
in the gap plan.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): fat-vs-thin runtime spike + fleet re-release pipeline

gaps.md e4 (the plan's ⭐ decide-now item). spike package
(spikes/runtime-versioning): the analysis — user functions execute
inside ShardDO and workerd has no dynamic code loading, so true-thin is
a distributed-transaction redesign, not a packaging change — plus live
probes for the three deciding hypotheses (cross-script DO bindings
under wfp, callback per-hop cost vs a 1ms viability line, fat-path
patch throughput arithmetic). provisional call: fat + pinned runtime +
automated forced re-release. that pipeline ships here too:
deployments record their runtimeVersion, and src/fleet/upgrade.ts
plans canary-first batches and halts on a dirty canary or breached
failure rate — a runtime patch becomes a paced batch job over the
existing build + health-gated release machinery.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): ring-2 pass — harden, close seams, finish product edges

security: github installations move to a staged-claim model (webhook
stages, owner/admin claims; recordPush only accepts claimed
installations and caps in-flight builds), domains.add enforces the
customDomains entitlement, and audit coverage lands for domains,
rollback, deletion requests, installation claims, and both suspension
crons. seams: build → deploy handoff via the runner's release port
(failed release keeps the artifact), stale-build self-healing cron,
superseded-release pruning (retain 3/project), and server-built pr
previews through the same pipeline. product: per-environment secrets
(all/production/preview/dev with kind-over-shared resolution + studio
picker), rollback button, suspension/deletion banners, org rename,
member role change (last-owner protected), project rename.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): switch billing to creem as merchant of record

Resolves gaps.md c3: creem (via @lunora/payment/creem) replaces the
stripe adapter as the platform's payment provider. As a merchant of
record it is the legal seller and calculates/collects/remits sales
tax/vat across 190+ jurisdictions, so the platform never inherits
worldwide tax compliance. Swaps the adapter wiring in the shard config
(CREEM_API_KEY / CREEM_WEBHOOK_SECRET / CREEM_TEST_MODE for the
sandbox), the webhook route + action to the creem-signature header,
the studio copy to creem product ids and hosted portal, and the docs.
Entitlements, dunning, plans, and quota enforcement are unchanged —
they ride the provider-agnostic subscriptions store.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): prepaid-credits overage billing core for creem

Verified against creem sdk 1.5.3: products are recurring/onetime only —
no metered subscription pricing — but creem ships a first-party credits
ledger (per-customer accounts, idempotent credit/debit by reference)
built for api metering. Overage is therefore prepaid: orgs buy credit
packs (one-time mor sales, tax handled by creem) and the platform
debits usage beyond the plan's included quota. Ships the pure core
(included quotas per plan, cost-plus overage rates, watermark-delta
debits with crash-safe idempotent references, exhausted → the existing
c1 suspension path, never negative), the overageDebits watermark table
with forward-only advancement, and 10 tests. The live credits api
wiring (CreditsLedgerPort) is the remaining 🌐 piece.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): creem credits-ledger adapter and fleet overage reconciliation

Completes the api/token-metering implementation over creem's
customerCredits api: a structural ledger adapter (balance reads via
bigint-safe strings, debits with the idempotent watermark reference,
missing account → null and never debitable), applyCreditPurchase for
the billing webhook (first purchase creates the account seeded with the
pack; later ones credit with the payment id as reference), the
organizations.creditsAccountId linkage (never overwritten once set),
and reconcileAllOverages — the fleet driver with per-org failure
isolation, watermark-advance strictly after a successful debit, and
exhausted balances handed to the c1 suspension hook.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): studio ux pass — usage meters, daily chart, command palette

Ring 3, patterns from the maple teardown (fsl-licensed observability
platform — ideas only, all code our own). usage tab: included-vs-used
plan-quota meters (amber at 80%, red past allowance, honest prepaid-
credits overage label) + a per-day request-volume chart over the new
usage.series query, rendered with a zero-dependency svg bar chart.
adds a ⌘k command palette (tab navigation + actions, substring match,
arrow/enter/escape keyboard flow, state reset by remount) wired into
the org dashboard. gaps.md gains the ranked ring-3 backlog (alerting
pillar, health charts, log-viewer upgrade, design tokens, onboarding
checklist, mcp surface, integrations hub).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): keep cron triggers within cloudflare cap

The control-plane Worker declared 4 distinct cron expressions (0 */1, 0
*/6, 0 */12, */1) — one over Cloudflare's hard limit of 3 Cron Triggers
per Worker, which would reject the deploy.

The lone 0 */12 trigger existed solely for "purge deleted organizations".
Fold that job into the existing 6h bucket: the purge gates on each org's
own 30-day retention cutoff, so a tighter cadence only shortens erasure
latency — it never erases early. Codegen drops the 0 */12 trigger, leaving
exactly 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01423xZDDqzhQ5D79vy25huF

* feat(cloud): observability ingest pipeline — issues + incidents (Phase 3) (#140)

* feat(cloud): add the observability ingest pipeline (issues + incidents)

Phase 3 of the observability plan — durable, cross-deployment monitoring
in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport.

- ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant
  `otlpSink` and the container exporter, decodes the error spans
  (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents
  through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous
  — the cloud app has no queue producer binding, so ingest inserts to D1
  directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the
  plaintext admin token.
- store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with
  `@lunora/fingerprint` (the same hash the local Studio computes, so a local
  Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts`
  member-authorized read/triage functions. A `TelemetryStore` adapter
  (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a
  guarded Pipeline→R2 archive, each a no-op without its binding.
- dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the
  `logStreams` entitlement, wired into `OrganizationDashboard`.
- bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket.

Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on
the cloud branch; the graft folds away once Phase 1 lands on alpha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* feat(cloud): observability alerts — rules + delivery (Phase 4) (#141)

* feat(cloud): add observability alerts — rules, firing + delivery

Phase 4 of the observability plan (the "watches while you sleep" tier),
stacked on the Phase 3 ingest.

- schema: `alertRules` (name, target issue/incident, threshold, channel
  email/webhook, destination, enabled) + `alerts` (fired-alert audit trail
  with firing→delivered state, notification denormalized).
- firing: the telemetry `ingest` mutation loads the org's enabled rules and
  fires each the first time a source's count crosses its threshold
  (`before < threshold <= after`, so exactly once), inserting a `firing`
  alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts`
  (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`.
- delivery: the `/v1/telemetry` edge handler delivers fired alerts
  best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps
  them delivered — never blocking or failing ingest.
- functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list,
  markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered).
- dashboard: `AlertsSection` (manage rules + recent fired alerts), gated
  behind the `logStreams` entitlement, wired into `OrganizationDashboard`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* fix(cloud): validate webhook alert destinations against SSRF

An alert rule's webhook `destination` is `fetch`ed by the control plane
when the alert fires, so an owner/admin could otherwise aim it at internal
infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) —
server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https
only, public host, no embedded credentials, no loopback/private/link-local
IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in
`deliverAlert` (never fetch an unsafe target — defense in depth for any
rule created before this guard). String-level, so it can't defeat DNS
rebinding, but it blocks the direct-address cases. Unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cloud): harden webhook SSRF guard

Two SSRF gaps in the Observability alert delivery path:

- deliverAlert followed webhook redirects, so a destination that passes
  isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the
  metadata IP). Set redirect: "manual" and reject 3xx responses.
- isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which
  the URL parser compresses to ::ffff:7f00:1) and the unspecified
  address (::) through. Reject the whole ::-prefixed non-global class.

Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already
blocked via WHATWG URL normalization; added as regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cloud): AI incident triage (@lunora/ai) — Phase 4C (#142)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(runtime): use LunoraError not undefined CirrusError in cloud endpoints

The scheduled-tick and queue-dispatch admin endpoints threw `new
CirrusError(...)`, a class that exists nowhere in the repo, so the file
failed to type-check (TS2304). The intended class is `LunoraError`, already
imported and used throughout the file.

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

* feat(cloud): full tenant log management with structured fields + trace correlation

Consume the structured, trace-correlated logs the framework now emits
(shared/log-event.ts) — the cloud log path kept only 3 severities + a flat
line and had no producer. Closes the framework side of Maple gap #2.

- Producer (GAPS.md B2, the missing piece): src/tail/worker.ts — the
  dispatch-namespace tail worker decodes each tenant `{source:"lunora",
  type:"log"}` console event (src/tail/parse.ts, pure + unit-tested), groups
  them per script, and POSTs batches to POST /v1/logs/tail. Holds one platform
  secret (LUNORA_TAIL_SECRET), not per-org deploy keys; the route resolves
  scriptName → org (logs.orgForScript) and stores via logs.ingestInternal.
  Deployed from tail.wrangler.jsonc.
- Store: tenantLogs widened to the full LogEvent shape — 7-tier severity,
  message, structured fields, functionPath, traceId/spanId, userId, shardKey —
  plus (scriptName, createdAt) and (org, traceId) indexes.
- Query: logs.list gained server-side levels/functionPath/traceId/search
  filters + a cursor and bounded limit, newest-first.
- UI: the studio Logs tab renders severity chips (filter), search, structured
  fields, and a short trace id per line.

Still 🌐: the provisioner setting tail_consumers on tenant scripts, an e2e run,
and correlating error/fatal lines to OTLP Issues by traceId (follow-up).

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

* fix(cloud): provision tenant bindings so deployed workers boot

The deploy handler built the provisioner spec with an empty binding set
(`bindings: {}`), so every uploaded tenant Worker was created with no
Durable Object binding and no `new_sqlite_classes` migration tag. A real
Lunora app always exports ShardDO, so it could never boot — the deploy
pipeline could only ship a binding-less worker.

The deploy request now carries the app's binding manifest (DO classes,
optional per-tenant D1/R2) which the CLI reads from `wrangler.jsonc`, and
the handler normalizes it to a spec that always includes the ShardDO
floor even when a caller under-declares or omits it. Malformed entries
are dropped and the DO list is capped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): tear down Cloudflare scripts for destroyed deployments

The lifecycle crons (cleanupExpiredPreviews, pruneSuperseded,
organizations.purgeDeleted) only transitioned a deployment to `destroyed`
— nothing ever deleted the Cloudflare dispatch script, so dispatch
namespaces grew unboundedly (the leak GAPS.md Ring-2 flagged as closed).

Add a `teardownAt` marker and a pure, port-injected `runTeardownSweep`
(per-target failure isolation, crash-safe idempotent off the marker),
wired into the control-plane Worker's scheduled() handler on the
hourly/6-hourly buckets — right after the crons that mark rows destroyed.
No-ops without Cloudflare credentials.

Per-tenant D1/R2 teardown-by-id still needs resource-id persistence and
is left as a follow-up; script deletion is the load-bearing fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): fold Analytics-Engine usage into the metering ledger

The dispatcher wrote one AE data point per tenant request, but nothing
ever read them back — createHttpAnalyticsReader had no caller, so
`platformUsage` only held what tenants self-report over POST /v1/usage
(nothing, in practice). Spend caps, the usage summary, and the usage
chart therefore evaluated an empty ledger.

Add a per-cell `usageReadAtMs` checkpoint and a pure, port-injected
`runUsageRollback` that delta-reads AE (`timestamp > checkpoint`),
attributes each dispatch script to its org/deployment, and appends
`requests` rows — then advances the checkpoint so re-runs never double
count. A per-row ledger failure is dropped rather than retried (under-
count, never double-bill — the same fail-safe as usage.rollup). Wired
into scheduled() on the hourly/6-hourly buckets; no-ops without
Cloudflare credentials.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): build-queue dispatcher (claim → run → drain)

`builds.claimNext` had no caller: enqueued builds sat untouched until the
24h expiry cron failed them with "no build runner picked this up". Add
the missing claim→run loop as a pure, port-injected `runBuildDispatch`
(bounded per-tick drain; a failed build never aborts the drain), fully
unit-tested against the runner ports.

Production activation stays gated on the runner's 🌐 seams — `execute`
(a throwaway Cloudflare Container running `lunora build`) and
`fetchSource` (GitHub App tarball) — which need live container infra, so
the dispatcher is not yet wired into scheduled(): claiming builds with no
executor would only burn them. This lands the verified logic so the
remaining work is purely the container seam, not the orchestration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* docs(cloud): split GAPS legend into wired vs pure-module (🧩)

The single ✅ conflated "tested pure function exists" with "feature runs".
Add a 🧩 marker for tested-but-uncalled modules, a dated wiring-pass
section covering the four gaps just addressed, and correct the two most
misleading inline entries:

- A3 builds: the claim dispatcher now exists (was missing); only the
  container execute() seam remains 🌐.
- C3 overage credits: reconcileAllOverages / applyCreditPurchase have no
  production caller (verified) — scheduling + webhook mapping are code
  (🔨), not credentials (🌐), so the honest status is 🧩, not "✅ core".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): boot-time route classification scanner

Port of Openship's route-scanner idea (Apache-2.0) to the /v1 router. The
control-plane routes each did inline auth then delegated to a self-
authorizing function, but nothing forced a *new* route to be classified —
an unclassified endpoint could ship silently and read as protected.

Every route now carries an explicit RouteSpec.auth (deployKey / session /
webhookHmac / tailSecret / adminToken / public), and
assertRoutesClassified runs when createDeployRouter builds the table: a
missing/unknown classification, a public route with no reason, or a
duplicate (method, path) throws at construction — the Worker fails to
start rather than serving an unclassified route. The flat dispatch tables
are derived from the one checked list (GET + POST unified). The spec's
opt-in `mcp` field is the allowlist the MCP surface will read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): MCP surface generated from the route registry

Port of Openship's "MCP tools derived from the route registry" idea
(Apache-2.0). A `/v1/mcp` JSON-RPC endpoint (tools/list + tools/call)
exposes only routes that opt in via RouteSpec.mcp, and every tool call
dispatches back through the real router carrying the agent's own bearer
credential — so it runs the identical auth + rate-limit + handler +
function-authz path as any HTTP caller; the MCP layer grants no privilege.

A hard deny-list (buildMcpTools) guarantees token/secret/tenant-access
routes (/v1/secrets, /v1/admin, /v1/invitations/send, /v1/logs/tail) and
the surface itself (/v1/mcp) can never become tools even if mis-annotated
— the same scope-escape guard Openship applies to tokens/auth/mcp. Only
bearer-callable (deployKey/adminToken) opted-in routes are eligible;
session/webhook routes are excluded. deployments.rollback is the first
tool exposed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* chore(codegen): regenerate _generated for teardownAt + usageReadAtMs

Keeps the emitted dataModel/shard/drizzle types consistent with the new
deployments.teardownAt and cells.usageReadAtMs schema columns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): tear down tenant D1 + R2, and test the sweep glue

Extends resource teardown past the dispatch script (#2). The lifecycle
sweep now also deletes the per-tenant D1 database and R2 bucket, resolved
by the same naming convention the provisioner creates them under (shared
tenantD1Name / tenantR2Bucket helpers — no drift, no new persistence).
New CF API methods: findD1DatabaseByName + deleteD1Database (uuid) and
deleteR2Bucket (name). Script + D1 delete are retryable; R2 is best-effort
(a non-empty bucket needs an S3-API object purge the teardown context
lacks — logged, left for follow-up). D1 (every .global() app has one) and
empty R2 buckets are now fully reclaimed.

Also extracts the scheduled() sweep glue into testable port-builders
(#4): teardownPorts + usageRollbackPorts over a structural ControlPlaneDb,
so the row→target mapping, the teardownAt stamp, the ledger insert, and
the per-cell checkpoint are unit-tested against a fake store instead of
living untested inside server.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): carry cronSpecs + bindings from wrangler on deploy

The cron fan-out read live deployments' cronSpecs, but nothing ever
populated them: deployments.create accepted the field yet the deploy
handler/router never passed it, so readCronTargets always returned [] and
the entire §2.4 tenant-cron fan-out had no data source (#3).

Add parseWranglerManifest — a pure reader that extracts the binding
manifest (DO classes / D1 / R2) and cron expressions from a tenant's
wrangler.jsonc — and thread cronSpecs through the deploy request →
handler → create mutation. The deploy client + CLI now forward both
bindings and cronSpecs, so a real deploy provisions what the Worker needs
and registers the crons the fan-out drives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): synthetic uptime monitoring with alerting

Adds external-vantage uptime to the control plane's Observability tier — the
piece a deployment can't self-report (if it's down, it can't say so).

- Probe: pure probeDeployment (generalizes the deploy-time healthCheck — GET,
  sub-500 = up, latency + timeout, never throws), a consecutive-failure state
  machine, and a summarizer, all unit-tested (src/uptime/probe.ts).
- Sweep: runUptimeSweep over injected ControlPlaneDb ports (mirroring the
  teardown/usage sweeps) probes every live deployment, records a uptimeChecks
  row, advances uptimeState, and fires an "uptime" alert the first time a
  deployment's failures cross a rule threshold — reusing crossesThreshold,
  renderAlert, and the alerts table/delivery pipeline (src/uptime/sweep.ts).
- Edge: server.ts scheduled() runs the sweep on the every-minute tick and
  delivers fired alerts over their channel (webhook/email), stamping the outcome.
- Alerts gain an "uptime" target (schema + createRule + renderAlert), so users
  configure "page me when my deployment is down" alongside issue/incident rules.
- Read side: lunora/uptime.ts (summary + recent queries, retention prune cron)
  backs a new Uptime dashboard section.

Cron triggers stay at 3 expressions (prune rides the 6h bucket, the probe rides
the existing every-minute tick). Full suite: 272 tests, lint:types clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019M6G6CAoLVrQMxDYg7BWq2

* fix(cloud): address thermo review of uptime monitoring

Security/correctness (branch audit):
- SSRF: the sweep fetched a deployment URL any org member can set, from the
  privileged control plane, with no guard. Gate every probe URL through the same
  isSafeWebhookUrl check the webhook path uses; an unsafe URL is skipped, not
  probed or recorded.
- Unbounded fan-out: probing every live deployment each minute could blow the
  Worker subrequest budget and record the overflow as false "down" alerts. Cap
  probes/sweep, bound probe concurrency (pool), and shard large fleets across
  ticks by id-rotated window.
- prune loaded the whole uptimeChecks table (the fastest-growing one) into
  memory; read a bounded oldest-first page instead (no range-where in the ctx-db).
- A failed delivery no longer stamps deliveredAt.

Quality (code review):
- Kill the duplication: the alert firing loop + AlertDelivery type were copy-
  pasted between telemetry.ts and sweep.ts. Lift one generic fireCrossedRules +
  AlertDelivery<TId> into src/telemetry/alerts.ts; both paths now share it, so
  they can't drift. TId carries the alert-row id (branded Id vs plain string).
- Extract recordCheck/advanceState; drop the dead latencyMs guard.

Full suite: 273 tests, lint:types clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019M6G6CAoLVrQMxDYg7BWq2

* feat(cloud): Traces tab — per-trace timelines in the console

The console shipped Logs + Issues but no Traces view. The cloud stores no OTel
spans (the OTLP ingest keeps only error spans → Issues), but the tenant log lines
already carry traceId/spanId/functionPath — so group them by traceId to get a
real per-trace timeline.

- `src/telemetry/traces.ts` (new): pure `foldTraces` — folds log rows into
  per-trace summaries (root function, time span, line count, peak severity,
  error flag), newest-active first. Unit-tested like the other telemetry logic.
- `logs.listTraces` query: reads the recent lines and folds them (members-only,
  bounded). Return type mirrored locally so codegen inlines it.
- `TracesSection` + dashboard tab (after Logs): lists recent traces (red when a
  line erred), drills each into its timeline reusing `logs.list` filtered to the
  traceId. Reuses the shared `.table` / `.log-badge-*` styles.
- GAPS.md B2 updated; a true span-duration waterfall (needs a span store) noted
  as deferred.

Note: eslint can't run on this branch (sonarjs crashes under typescript 7 —
the pin that fixes it landed on alpha, not here); typecheck + tests are green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* feat(cloud): waterfall look for the Traces tab

Design pass to give the Traces view a best-in-class OTel-dashboard feel, on the
app's own dark tokens (no external code — design inspiration only, per GAPS.md's
"ideas, not source"):

- List: the Span column is now a relative duration bar (scaled to the longest
  trace on screen) + severity-coloured, so slow/hot traces read at a glance.
- Drill-in: replaced the plain log dump with a **waterfall** — each line is a bar
  placed on the trace timeline (left = offset, width = gap to the next line),
  severity-coloured, with the offset, badge, span id, function, and message. A
  header with the trace id / span count / duration and a Close button.

Bar widths reflect log-line timing (the cloud stores no span durations); a true
span waterfall would need a span store, still noted in GAPS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* feat(cloud): capture spans as observations (Traces phase 1)

The OTLP ingest kept only error spans (→ Issues) and threw away every span's
timing + nesting. Now it persists them all — the span store the Langfuse
teardown pointed to, reimplemented cleanroom on our schema (no Langfuse code).

- `decodeObservations` (src/telemetry/otlp.ts, pure + unit-tested): decodes
  EVERY span with real startedAt/endedAt→durationMs and traceId/spanId/
  parentSpanId (the fields the decoder previously ignored).
- `observations` table (.global(), by_trace + by_org_started indexes) +
  `telemetry.ingest` persists them, additive to the error→Issue fold; router
  decodes + forwards them; `pruneObservations` cron trims at 48h like the logs.

Unlocks phase 2 (traces.list/get + a real-duration nested waterfall). True
nesting also needs the framework to emit ctx.trace child spans over OTLP —
today the runtime emits one flat span per RPC (no parentSpanId), noted in GAPS.

eslint still can't run branch-wide (sonarjs vs typescript 7); tsc + 284 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* feat(cloud): standard OTLP ingest API (/v1/traces, /v1/logs)

Make the cloud a real OpenTelemetry endpoint — any OTel SDK/Collector can ship to
it, not only Lunora's own otlpSink (mirrors Maple's ingest.maple.dev/v1/traces
and Langfuse's OTLP endpoint; the shape is the open OTLP spec, no third-party
code).

- `POST /v1/traces` — bearer-authed (Authorization: Bea…
prisis added a commit that referenced this pull request Jul 22, 2026
…rts, sessions, dashboards (#179)

* feat(studio): browse the durable log archive in the Logs panel (#155)

* feat(studio): browse the durable log archive in the Logs panel

Add a third "Archive" feed to the studio Logs panel that reads the durable
ctx.log archive pipelineLogSink writes to R2 (Iceberg / R2 Data Catalog).

- @lunora/runtime: a new admin-gated `/_lunora/admin/logs/archive` route
  (`log-archive-admin-routes.ts`) runs `createPipelineLogReader` server-side —
  the R2 SQL token stays on the worker, only decoded `{ rows, nextCursor }`
  reaches the browser. Reads creds from env (`R2_SQL_*`, `CLOUDFLARE_ACCOUNT_ID`
  fallback) + the table from a new `logArchive` WorkerOption. Fails closed with
  `LOG_ARCHIVE_NOT_CONFIGURED` when unwired.
- @lunora/client: `queryLogArchive(query)` method + re-exported PipelineLog*
  wire types (owned by @lunora/runtime).
- @lunora/studio: a self-contained `ArchiveFeed` (function/user/min-level
  filters, keyset "Load more", a "not configured" empty state distinct from an
  error) rendered under the new Archive tab; `errorCode` helper.
- Docs + API snapshots updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* refactor(studio): drop try/finally in ArchiveFeed for React Compiler

The React Compiler bails on a `try` with a `finally` clause (React Doctor
`react-hooks-js/todo`), so the component missed automatic memoization. Reset
`loading` in each branch instead, matching the repo's no-`finally` pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* refactor(studio): address thermos review of the archive feed

Code-quality + correctness follow-ups from the two-pass review:

- Extract the duplicated LEVEL_VARIANT map into `log-level-variant.ts`, shared by
  the live Logs feeds and the Archive feed (restores the exhaustive
  `Record<LogLevel, BadgeVariant>` type — drops the `?? "outline"` fallback).
- Collapse the four `view !== "archive"` readout guards in logs-panel into one
  `view === "archive" ? <ArchiveFeed/> : <>…</>` branch.
- Drop the `JSON.parse(JSON.stringify(baseQuery))` round-trip in the fetch effect
  — pass `baseQuery` directly, keying the effect on `querySignature`.
- Guard `loadMore` against a cross-filter race: a page-2 fetch that resolves
  after a filter change is dropped (via `activeSignatureRef`) instead of
  appending stale rows / overwriting the cursor.
- Show a "Loading…" placeholder on the initial fetch instead of a blank panel.
- Type `minLevel` state as `"" | ContextLogLevel` (removes a cast).
- Move the `LOG_ARCHIVE_NOT_CONFIGURED` sentinel to `shared/log-archive.ts` so
  runtime and studio share one source of truth with no dependency edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* refactor(studio): address CodeRabbit review of the archive feed

- Fold ArchiveFeed's five fetch-related useState into one useReducer, so each
  fetch transition (loading / loaded / append / notConfigured / failed /
  pageFailed) is a single dispatched action (React Doctor prefer-useReducer).
- Use the imported `ChangeEvent` type instead of the `React.*` namespace, matching
  logs-panel.tsx.
- Gate the toolbar `LiveError` on `view !== "archive"` so the disabled Errors
  feed's live-connection state can't leak into the (WS-less) Archive tab.

The CodeRabbit "cast env to LogArchiveEnvironment" suggestion is intentionally
skipped: `env ?? {}` narrows to `{}`, which is assignable to the all-optional
LogArchiveEnvironment (lint:types is green), and eslint's
no-unnecessary-type-assertion rejects the cast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* polish(studio): clear prior error on a fresh archive fetch

Thermos re-review nice-to-have: the reducer's `loading` action now clears
`error`/`notConfigured` (matching the kv reducers' `submitStart`), so retrying
after a failure shows the loading placeholder instead of the stale error line.
Rows are kept, so paging / filter-change refetches don't blank the table.

(Kept `default: return state` to stay consistent with the existing kv reducers
rather than introduce a one-off `unreachable` exhaustiveness guard.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(release): @lunora/runtime@1.0.0-alpha.32 [skip ci]\n\n## @lunora/runtime [1.0.0-alpha.32](https://github.com/anolilab/lunora/compare/%40lunora%2Fruntime%401.0.0-alpha.31...%40lunora%2Fruntime%401.0.0-alpha.32) (2026-07-21)

* chore(release): @lunora/client@1.0.0-alpha.26 [skip ci]\n\n## @lunora/client [1.0.0-alpha.26](https://github.com/anolilab/lunora/compare/%40lunora%2Fclient%401.0.0-alpha.25...%40lunora%2Fclient%401.0.0-alpha.26) (2026-07-21)

### Dependencies

* **@lunora/runtime:** upgraded to 1.0.0-alpha.32

* docs: add cirrus cloud platform plan

Reverses the managed-deploy-plane won't-do (VOID-TEARDOWN.md §0/§6,
CONVEX-PARITY.md #23) with a scoped managed tier: Workers for Platforms
data plane, Convex-shaped product model (teams/projects/prod+dev+preview
deployments), PartyKit-style managed-vs-BYO CLI split, and a phased
roadmap starting with remote-binding dev. Synthesized from a repo
inventory, a Convex Cloud teardown, and a GitHub/Cloudflare-primitives
survey.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: fold supabase platform teardown into cloud plan

Adds Supabase as a reference model: the OSS/proprietary cut line,
IS_PLATFORM single-codebase studio pattern, Branching 2.0 preview DX
(and its pain points to fix: empty branches, hourly branch billing
outside spend caps), the Management API + OAuth-apps growth channel,
and the structural cost advantage WfP gives over per-project VMs.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add wfp constraints, eject path, spike checklist

Gap review of the cloud plan: documents that cron triggers are
silently dropped for namespaced user Workers (with SchedulerDO
alarm-based fan-out mitigation), queue-consumer and send_email
verification items, KV account-limit multiplexing, EU jurisdiction
toggle, a cirrus-eject portability command built on existing
export/import RPCs, namespace-wide observability reuse, managed
backups + abuse controls in Phase 4, and a Phase 1 constraint spike.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: verify cloud plan claims against cloudflare docs

Fact-checked every Cloudflare claim in CLOUD-PLAN.md against the
official docs: WfP pricing, KV/D1/R2 account limits, DO/R2
jurisdictions vs D1 location hints, CF for SaaS hostname pricing, and
remote-bindings GA versions all confirmed. Adds three newly verified
constraints: no gradual deployments for user Workers (rollback must be
platform-side bundle re-upload), the 1200-req/5-min account API rate
limit on provisioning, and outbound-Worker TCP/DO interception
trade-offs.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add cell-based scaling architecture to cloud plan

Answers how the managed tier scales without hitting account limits or
risking platform-wide blocks: script-resident tenant state with lazy
inference-driven provisioning, multi-account cells with cell IDs baked
into identifiers from day one, a per-cell API token-bucket scheduler,
a tenancy graduation ladder up to managed-BYO and the Tenant API, and
abuse containment to keep tenant abuse from looking like platform
abuse.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: scope alchemy to cell bring-up, not tenant deploys

Records the provisioning-engine decision: the per-tenant deploy path
stays hand-rolled on cloudflare-typescript (control-plane DB as the
single source of truth, cell scheduler, progress events, rollback
artifacts); Alchemy (pre-1.0, v2 rewrite underway, no confirmed
dispatch-namespace resource) is a candidate only for low-cardinality
cell bring-up IaC, with Terraform/Pulumi as the fallback.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: correct alchemy facts (dispatch-namespace resource, v0.93)

Re-checked Alchemy against GitHub/npm: it is v0.93.12 (Apache-2.0) and
does ship a dispatch-namespace (Workers for Platforms) resource — my
earlier 'lacks a confirmed dispatch-namespace resource' was wrong and
'v0.9x' undersold it. Recommendation is unchanged (hand-roll the
per-tenant deploy, use Alchemy for cell bring-up) but now rests on the
real reason — source-of-truth shape and deploy-orchestration concerns,
not capability — with a re-evaluation trigger at a stable 1.x.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: adopt alchemy as the provisioning engine

Decision: Alchemy is the provisioning engine across cell bring-up,
per-tenant managed deploy, and BYO. Verified it ships DispatchNamespace/
Worker/D1/R2/DO resources plus a built-in D1StateStore and runs inside a
Worker (await alchemy(scope) -> finalize/destroy). Backing each tenant
scope with the control-plane D1 collapses the two-sources-of-truth
concern into one store. The per-cell rate-limit scheduler now paces
finalize() runs; bundling stays in the Vite pipeline; rollback re-
converges to a prior R2-retained bundle. Risk of a 0.x dependency
contained behind a @cirrus/provision adapter with cloudflare-typescript
as fallback; spike open items recorded.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: build on alchemy v2 (alchemy@next, 2.0.0-beta.55)

Per decision to start on the v2 line: target alchemy@next (verified
2.0.0-beta.55, Effect-based) to avoid a v1->v2 migration mid-build,
with v1 0.93.x as the named fallback. Records the trade-offs (beta
churn, Effect pulled into the control-plane tree, quarantined behind
the @cirrus/provision adapter) and turns the unverified v2 facts
(DispatchNamespace resource + D1/DO state store, confirmed on v1 only
since docs/CDN were unreachable) into hard Phase 1 spike gates with
v1 fallback per surface.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: commit fully to alchemy v2, drop v1 fallback

Remove the v1 (0.93.x) fallback hedging throughout: v2 (alchemy@next,
2.0.0-beta.55) is the engine outright. Spike gates remain but now
resolve via owned shims or upstream contributions rather than retreat
to v1; a hard unresolvable 'no' escalates the engine decision instead
of silently dual-tracking.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add forgotten must-haves + fleet runtime-versioning risk

Gap pass on the cloud plan. New risk #8 (the load-bearing one): the
Cirrus runtime is bundled into each tenant Worker, so a security patch
means redeploying the whole fleet unless the tenant Worker is made
'thin' against a central runtime — a fat-vs-thin decision that must be
made before Phase 1 since it shapes the bundle format, deploy API, and
vite emit. New section 7 collects launch-blocking gaps the plan had
assumed away: control-plane DB durability/DR, cross-cell disaster
recovery, secrets-at-rest + cell-token custody, frontend-hosting scope,
AUP + bill-shock/cryptomining controls, billing/MoR/tax, GDPR
processor/DPA/SOC2, account offboarding + right-to-erasure, platform
self-observability + status page, dispatcher canary, and a staging cell.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): scaffold control-plane app built on cirrus

First implementation step from CLOUD-PLAN.md: a new apps/cloud workspace
app that dogfoods Cirrus as the platform's own control-plane backend.

- cirrus/schema.ts: control-plane data model (cells, organizations,
  members, projects, deployments, deployKeys, auditLog), all .global()
  (D1) — the plan's 'Worker + D1' control plane.
- cirrus functions: organizations/projects/deployments/cells/deploy-keys
  (create/list/issue/updateStatus), with owner seeding + audit trail.
- src/server.ts: control-plane Worker entry wiring D1-backed global tables.
- src/provision.ts: the @cirrus/provision seam — the sole coupling to the
  Alchemy v2 engine (stub that rejects until the Phase 1 spike wires it).
- configs (package.json/tsconfig/project.json/wrangler.jsonc/eslint/vitest),
  README, and a provision test.

Verified: codegen clean (no advisories), eslint clean, tsc --noEmit
passes, vitest green.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add deploy-orchestration core

Builds the next control-plane layer on the scaffold's Provisioner seam,
all pure/testable (no live Cloudflare needed):

- token-bucket.ts: per-cell API budget (§2.5), models CF's 1200/5min
  account limit; deterministic + clock-injectable.
- scheduler.ts: CellScheduler paces/serializes provisioner work against
  the bucket with priority ordering + a concurrency cap.
- orchestrator.ts: runDeployment state machine emitting queued →
  provisioning → live/failed progress events (§2.2); destroyDeployment
  for preview-TTL/project teardown.
- keys.ts: deploy-key format/parse/hash helpers; deploy-keys.ts mutation
  refactored to use them (one tested place for the format + SHA-256).

17 tests across 5 files; eslint + tsc --noEmit clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add org authorization + deploy-key lifecycle

Closes gaps found reviewing the control plane:

- authz.ts: assertMember(ctx, orgId, roles?) — the org ACL gate. Every
  org-scoped function now verifies the caller is a member with a
  permitted role, closing an IDOR hole where any signed-in user could
  read/mutate any org by passing its id. Applied across projects,
  deployments, deploy-keys.
- members.ts: list / add / remove so memberships can actually be granted
  (owner is seeded on org create; admins/owners manage the rest).
- deploy-keys: verify (the deploy API's auth path — match by SHA-256,
  reject revoked, bump lastUsedAt, return the DB-authoritative target)
  and revoke (leaked-key mitigation); lastUsedAt/revokedAt are now live.
- deployments: create checks the project belongs to the org; updateStatus
  loads the deployment and gates on its org (documented as the system
  seam for the orchestrator).

codegen clean, eslint + tsc --noEmit clean, 17 tests pass.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): deploy API endpoint, deploy-key auth, handler tests

Lands the three remaining pieces together:

- Deploy API: POST /v1/deploy mounted via the httpRouter seam (src/deploy/
  router.ts) → pure handler (src/deploy/handler.ts) authenticates the bearer
  deploy key, records a queued deployment, drives runDeployment through the
  per-cell scheduler, and streams NDJSON progress (accepted→queued→
  provisioning→live/failed→done), patching status per phase.
- Auth path: investigation showed internalMutation is unreachable from the
  HTTP action-context dispatch (no system flag → RPC 404), so verify/
  updateStatus stay public; instead added deploy-key authorization
  (authz.authorizeDeployKey) and a dual-path (member session OR deploy key)
  on deployments.create/updateStatus, so CI deploys need no user session.
  Corrected the stale 'should become internalMutation' comments.
- Tests: handleDeployRequest (401/403/400 + success and failure streaming +
  status transitions) and authz (assertMember + authorizeDeployKey) via a
  fake ctx. 28 tests / 7 files; eslint + tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): refresh status — deploy API + auth now in place

* chore(cloud): track generated schema snapshot

Matches the apps/playground convention — .cirrus-schema.json is the
codegen schema snapshot used for migration/drift detection and is
committed, not gitignored.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): preview lifecycle, GitHub webhook, deploy client (Phase 1/2)

Phase 2:
- Preview deployments are TTL'd: deployments.create stamps expiresAt for
  kind=preview (src/deploy/preview.ts: deterministic previewScriptName +
  5-day previewExpiry); an hourly cron (cirrus/crons.ts -> internal
  deployments.cleanupExpiredPreviews) marks expired previews destroyed.
  Worker gains scheduled(); wrangler cron trigger added.
- GitHub webhook (src/github/webhook.ts): HMAC-SHA256 verify + pull_request
  -> preview-intent parsing, mounted at POST /v1/github/webhook.

Phase 1:
- Deploy client (src/deploy/client.ts): the cirrus-deploy core — POSTs to
  /v1/deploy and consumes the NDJSON progress stream.

13 new tests (41 total); codegen/eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: update roadmap — Phase 0 shipped, Phases 1-2 status

Phase 0 (remote-binding dev) is already implemented in the framework
(@cirrus/config remote-bindings + @cirrus/vite plugin + cirrus dev;
30 tests) — corrected from 'not started'. Phases 1-2 marked
substantially-built with the live-Cloudflare-dependent remainder
(Alchemy provisioner, dispatcher, e2e validation) called out.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): team invitations + billing quotas; repo-extraction guide (Phase 3/4)

Phase 3 (hosted studio sliver):
- Team invitations (cirrus/invitations.ts + invitations table): invite/list/
  revoke/accept, single-use SHA-256-hashed tokens (plaintext mailed once),
  owner-admin gated; accept-by-token adds the caller as a member.

Phase 4 (billing sliver):
- Plans + quota entitlements (src/billing/plans.ts) on @cirrus/payment's
  entitlements model — free/pro/enterprise limits + feature flags, with
  effectiveLimit/withinQuota and a free-tier fallback for non-subscribers.

Portability (move to a private repo):
- EXTRACT.md documents the mechanical extraction; audit confirms the app
  imports only published @cirrus/* entry points (no monorepo-internal reaches).

6 new tests (47 total); codegen/eslint/tsc clean; secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): real Cloudflare provisioner, quota enforcement, webhook project resolution

Provisioner (Phase 1 — the big one): replaces the rejecting stub with a real
implementation over a typed Cloudflare REST port (src/cloudflare/api.ts):
deploy provisions per-tenant D1/R2, uploads the user Worker into the dispatch
namespace with binding + DO-migration metadata, applies secrets, returns the
bundle hash + routed URL; destroy deletes the script. Port-injected so it's
tested with a fake; plug in CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN to run.
(REST via fetch rather than the unverifiable alchemy@next beta — same seam.)

Quota (Phase 4): plans.ts gains planLimit/withinPlanQuota; projects.create and
members.add enforce the org plan's limits.

Preview automation (Phase 2): projects gain githubRepo + byGithubRepo lookup;
the webhook resolves the connected project and returns the preview script name.

Env documented (.dev.vars.example + wrangler vars). 50 tests; eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): dispatcher Worker + hosted-studio admin-RPC proxy (Phase 1/3)

Phase 1 — dispatcher Worker (the request-path front door): resolveTenant
maps {scriptName}.{appDomain} (and custom domains via injected lookup) to a
dispatch-namespace script; the worker forwards via env.DISPATCHER.get with
per-plan limits. Separate deployable (dispatcher.wrangler.jsonc).

Phase 3 — admin-RPC proxy: proxyAdminRequest authorizes org membership,
forwards the admin RPC to the tenant's /_cirrus/admin/* with that
deployment's admin token, and records an audit entry. Pure (deps injected).

7 new tests (57 total); eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): cirrus login/link/deploy CLI commands (Phase 1)

Pure command logic over a ConfigStore + the deploy client: login persists the
API endpoint + deploy key, link binds a project, deploy streams a managed
deploy (requires login+link). File-backed store at ~/.cirrus/cloud.json for
the Node CLI; cerebro registration in @cirrus/cli calls these.

3 new tests (60 total); eslint/tsc clean; secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: roadmap status — control-plane backend feature-complete as code

All phases' backend code is built + unit-tested in apps/cloud (60 tests):
real REST provisioner, dispatcher, CLI, preview lifecycle, GitHub webhook,
team invitations, admin-RPC proxy, quota enforcement. Remaining items are
the ones needing live Cloudflare / external services / the studio UI.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): admin-proxy live wiring, usage metering, custom-hostname port (Phase 3/4)

Phase 3 — admin proxy mounted at POST /v1/admin: deployments now carry the
platform-minted tenant adminToken (set as the worker's CIRRUS_ADMIN_TOKEN
secret + stored on the row), deployments.adminTarget resolves {url, adminToken}
after asserting membership, and the router forwards to the tenant's
/_cirrus/admin/* with an audit-log.record entry.

Phase 4 — usage metering: usageEvents table + internal record mutation +
member summary query over a pure aggregateUsage roll-up. Custom hostnames:
CloudflareApi.createCustomHostname (Cloudflare for SaaS, zone-scoped REST).

Router refactored into per-route handlers. 64 tests; eslint/tsc/secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: roadmap — admin proxy mounted, usage metering + custom-hostname port added

* feat(cloud): add hosted studio react spa

Build the hosted-studio frontend for the Cirrus Cloud control plane: a
better-auth-gated React SPA served on one origin with the control-plane
Worker via @cirrus/vite.

- src/client: main/auth-client/Login, App auth gate, OrganizationList,
  OrganizationDashboard with tabs for projects, deployments, members,
  deploy keys, invitations, and usage; AsyncList loading/empty helper.
- Wire @cirrus/auth into src/server.ts (createAuth + cirrusD1Adapter,
  ensureMigrated, handleAuthRequest, authAdmin, resolveIdentity) and add
  AUTH_SECRET/AUTH_URL env + .dev.vars.example entries.
- Switch package scripts to vite (build/dev), add react/react-dom +
  @cirrus/react/@cirrus/auth deps, vite.config.ts, index.html, and the
  DOM lib + jsx in tsconfig.
- eslint: client section (filename-case, react-perf, void), browser
  globals; ignore vite.config.ts.
- Refresh README + CLOUD-PLAN status to reflect the studio UI.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add billing, metering, and hardened auth

Billing on @cirrus/payment (§4): org id is the payment referenceId. Wire a
Stripe adapter into createShardDO({ payment }); add cirrus/billing.ts with
checkout/portal actions, entitlements/subscription reads (resolved through
CIRRUS_CLOUD_PLANS with a free-tier fallback), and a signature-verified
processWebhook mounted at POST /v1/billing/webhook. The studio gains a
Billing tab.

Platform metering (§4): rename the resource-metering table to platformUsage
(freeing usageEvents for @cirrus/payment's billing ledger), add a deploy-key
authenticated usage.ingest mutation + POST /v1/usage endpoint, and enforce
per-plan runtime limits in the dispatcher (limitsForPlan → DISPATCHER.get).

Auth hardening (§3) on @cirrus/auth/better-auth: mail-backed email
verification + password reset (@cirrus/mail), optional GitHub/Google OAuth,
admin/twoFactor/passkey plugins, built-in auth rate limiting, plus a per-IP
@cirrus/ratelimit cap on the /v1/* surface. Invitations now email the token
via POST /v1/invitations/send (never shown in the browser). The Cirrus
organizations/members model stays the single org source of truth (better-auth
organization plugin deliberately omitted).

Add deps (@cirrus/mail, @cirrus/ratelimit, stripe), tests for the router
routes + rate limiting + per-plan limits (69 total), and reconcile the README
+ CLOUD-PLAN status (the provisioner is a real Cloudflare REST impl, not a
stub).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): enforce entitlements, wire metering source, add secrets

Close the billing loose ends and add the metering source, tenant secrets,
and an audit-log view.

Entitlements (close loose end #1): quota is now enforced against live
subscription state (cirrus/entitlements.ts resolves from the synced
`subscriptions` table) rather than the static organizations.plan column —
projects/members creation call assertWithinQuota, so a Stripe upgrade raises
limits immediately with no column to sync.

Per-plan dispatch limits (close loose end #2): deployments.planForScript +
a bearer-gated GET /v1/tenants/plan endpoint + a cached plan resolver in the
dispatcher (createPlanResolver) wire resolvePlan, so runtime limits actually
scale per plan instead of always falling back to free.

Metering source: the dispatcher emits one Analytics Engine data point per
tenant request (src/metering/analytics.ts); a reader port + HTTP impl and an
hourly usage.rollup compaction cron complete the pipeline alongside the
existing /v1/usage ledger ingest.

Tenant secrets (§7): AES-256-GCM envelope encryption at the edge
(src/secrets/crypto.ts), a secrets table (ciphertext + IV only), store/list/
listEncrypted/remove functions, POST /v1/secrets, deploy-time materialization
into the tenant Worker, and a studio Secrets tab.

Studio: add Secrets + Activity (audit log) tabs; add audit-log.list.

Tests: crypto round-trip, plan resolver caching/fallback, entitlement quota,
analytics writer/reader (83 total). Docs + .dev.vars.example updated.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): address review findings (rollup atomicity, deploy failure, dedup)

Apply /review findings on the recent billing/metering/secrets work:

Correctness:
- usage.rollup: the D1 global backend has no multi-statement transaction, so
  the old insert-summed-then-delete-originals order could double-count (over-
  bill) on a mid-rollup crash. Reorder to delete the extras first, then patch
  the surviving row's total last — a crash can now only under-count, never
  leave a summed row beside surviving originals.
- deploy handler: a tenant-secret decrypt failure (corrupt secret / rotated
  key) threw inside the NDJSON stream and left the deployment stuck in
  `accepted`. Catch it and transition to `failed` with a status update.
- POST /v1/secrets: encryption/config failures (e.g. a malformed
  SECRET_ENCRYPTION_KEY) now return 500, not a misleading 403 (kept distinct
  from the membership 403 the store mutation raises); reject the reserved
  CIRRUS_ADMIN_TOKEN secret name up front instead of silently clobbering it.
- studio: drop the plan picker from org creation — limits now come from live
  subscription entitlements, so selecting a paid plan at create-time granted
  nothing. Orgs start free; upgrade via the Billing tab.

Cleanup:
- Extract the cross-org IDOR guard into authz.assertRowInOrg and call it from
  secrets/members/deploy-keys/invitations (was four byte-identical copies).
- Remove dead plans.ts exports planLimit/withinPlanQuota (superseded by
  entitlements-based quota); add a single highestPlan/PLAN_PRECEDENCE helper
  and use it in deployments.planForScript (was a hand-rolled tier ladder).
- Memoize the Stripe payment config per isolate (was rebuilt on every shard
  request that touches ctx.payments).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* test(cloud): validate websockets through dispatch (phase 1 spike)

Validate the hottest path — hibernated-WS subscriptions + per-invocation
limits through env.DISPATCHER.get() — the least-documented WfP case (risk #3).

- spikes/ws-dispatch/: a runnable harness for live validation on a real
  dispatch namespace. A framework-free hibernatable-WebSocket Durable Object
  (the exact primitive ShardDO uses: acceptWebSocket + webSocketMessage),
  deployable into the namespace, plus a zero-dep Node probe that drives it
  through the dispatcher and asserts: (1) the WS upgrade survives the dispatch
  hop (101 + live socket), (2) a hibernated server push (broadcast) reaches the
  socket — the mutation-to-subscription shape, (3) cpuMs-limit behaviour. The
  README documents deploy/run, pass/fail, and the expected results + caveats.
- __tests__/dispatcher-ws.test.ts: unit-pins the dispatcher forwarding
  contract (returns the tenant 101+webSocket response unchanged, applies
  per-plan limits, meters the upgrade once) — runs in CI, no infra needed.
- dispatcher worker: clarifying comments on WS pass-through + per-frame
  metering semantics. CLOUD-PLAN risk #3 now references the harness.

The dispatcher half is verified here (94 tests); the end-to-end behaviour
needs a live Cloudflare account + the Workers-for-Platforms add-on to run.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant cron fan-out through dispatch (wfp workaround)

Cloudflare drops triggers.crons for Workers in a dispatch namespace, so tenant
cron jobs never fire. Fan them out from the control plane (CLOUD-PLAN §2.4).

- @cirrus/runtime: add an admin-gated POST /_cirrus/scheduled tick endpoint
  that runs a cron expression's jobs through the SAME handleScheduled path the
  native scheduled() trigger uses (user crons + code crons + backup), so a
  platform can drive a namespaced tenant's crons over HTTP. (Dispatch stubs
  expose only fetch()/connect() — no scheduled()/queue() — so HTTP is the only
  transport in.)
- src/fanout/cron.ts: pure 5-field cron-expression matching (lists, ranges,
  steps, dom/dow OR semantics) + dueTicks + fanOutCron orchestration.
- control plane: capture each tenant's cronSpecs on the deployments row at
  deploy; an every-minute heartbeat cron (cirrus/fanout.ts) makes codegen emit
  the */1 trigger, and server.ts scheduled() reads live cron targets and ticks
  each due tenant via env.DISPATCHER.get(script).fetch('/_cirrus/scheduled')
  with the per-deployment admin token (kept in-process — never exposed). Adds
  the DISPATCHER binding to the control-plane wrangler.

Tests: cron matching, dueTicks, fanOutCron (103 cloud tests; 337 runtime
tests still green). Live validation on a dispatch namespace pending; queue
consumer fan-out is the remaining half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant queue-consumer fan-out through dispatch (wfp workaround)

WfP namespaced Workers can't be queue consumers, so tenant queue-backed work
(@cirrus/mail sends, scheduler queue-workpool) never drains. Fan it out from a
platform-owned consumer (CLOUD-PLAN §2.4) — the queue counterpart to the cron
fan-out.

- @cirrus/runtime: add a `queueHandler` option + an admin-gated
  POST /_cirrus/queue endpoint that reconstructs the batch and invokes it,
  returning the message ids to retry. (Dispatch stubs are fetch-only, so HTTP
  is the only transport into a namespaced tenant.)
- src/fanout/queue.ts: pure grouping of a shared-queue batch by the producing
  tenant's script (envelope `{ script, body }`) + fan-out orchestration that
  collects per-message retries and retries a whole group on delivery failure.
- control plane: the account-level Worker is the consumer — server.ts queue()
  drains the shared cirrus-tenant-queue, resolves each tenant's admin token
  in-process (never exposed), forwards sub-batches via
  env.DISPATCHER.get(script).fetch('/_cirrus/queue'), and acks/retries per the
  tenant reply. Adds the queues.consumers binding to the control-plane wrangler.

Tests: groupByTenant + fanOutQueue (108 cloud tests; 337 runtime tests green).
Live validation on a dispatch namespace + a producer-side script-tagging helper
remain.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): align with the lunora rebrand + reuse @lunora/analytics

Rebased onto alpha, which renamed the framework cirrus → lunora. Reconcile the
control-plane app and reuse a newly-shipped package.

Rebrand:
- npm scope @cirrus/* → @lunora/* across deps + imports.
- app functions dir cirrus/ → lunora/ (+ tsconfig/eslint globs, _generated
  paths, the committed schema snapshot → .lunora-schema.json).
- reserved paths /_cirrus/* → /_lunora/* (incl. the new scheduled/queue tick
  endpoints) and the runtime-injected env.__lunoraCtx; renamed exported symbols
  (LunoraError, LunoraClient/Provider, useLunora, lunoraD1Adapter, LunoraAuth*,
  LUNORA_CRONS/FUNCTIONS); vite plugin cirrus() → lunora(); CLI config dir
  ~/.cirrus → ~/.lunora.
- wire the new required GlobalIntrospector.facetColumn via @lunora/d1's
  facetGlobalColumn.

Reuse:
- src/metering/analytics.ts is now a thin domain layer over @lunora/analytics
  (createAnalytics writeDataPoint + createAnalyticsSqlClient AE-SQL reader)
  instead of a hand-rolled writeDataPoint + HTTP SQL client.

Verified the rest is genuinely cloud-specific (cron-expression matching, AES-GCM
secret crypto, the Cloudflare REST provisioner, the per-cell CF-API token
bucket) — no upstream equivalent to fold into.

103→108 cloud tests green; runtime 379 tests green; tsc/eslint/build clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): rebrand the product Cirrus Cloud → Lunora Cloud

Complete the lunora rebrand to the product layer (the framework already moved):

- brand prose Cirrus Cloud → Lunora Cloud across code comments, README,
  EXTRACT, the studio (index.html title, Login/dashboard), and CLOUD-PLAN.md.
- env vars CIRRUS_* → LUNORA_*: LUNORA_ADMIN_TOKEN and LUNORA_MAIL_CAPTURE are
  functional (read by @lunora/mail); LUNORA_APP_DOMAIN / LUNORA_CELL and the
  VITE_LUNORA_URL client var follow for consistency.
- the LUNORA_CLOUD_PLANS entitlements constant.
- infra names cirrus-* → lunora-*: worker names (lunora-cloud, lunora-dispatcher),
  dispatch namespace (lunora-production), shared queue (lunora-tenant-queue),
  AE dataset (lunora_tenant_usage), the lunora.app apex, and the deploy
  dispatch-namespace prefix.
- the hosted-CLI verbs (lunora login/link/deploy) and config dir ~/.lunora.
- docs' reserved-path/marker refs (/_lunora/*, __lunora_admin__, env.__lunoraCtx).

108 cloud tests green; tsc/eslint/build clean; zero residual `cirrus` references.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* refactor(cloud): migrate functions to fluent builder api

Adapt the cloud control-plane functions to alpha v1.0.0-alpha.1's fluent
function builders: kind.input({...}).<terminal>(({ ctx, args }) => ...)
replaces the removed object form kind({ args, handler }). Regenerate
_generated/* and pick up codegen's observability block in wrangler.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): license under polyform noncommercial

The control plane is the proprietary product layer, so it must not carry
the framework's FSL-1.1-Apache-2.0 (which grants broad commercial rights).
Apply PolyForm Noncommercial 1.0.0: any noncommercial purpose is permitted,
but commercial use requires a separate license. Replaces UNLICENSED.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): adopt @lunora/bindings/analytics after package fold-in

The latest alpha folded @lunora/analytics into @lunora/bindings (subpath
export ./analytics, identical API) and codegen now emits
_generated/functions.ts importing @lunora/values directly. Swap the
dependency and import specifiers, declare @lunora/values, regenerate
_generated/*, and reconcile the lockfile.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): address pr review findings

- secrets: assert the project belongs to the org in store/list/
  listEncrypted and scope queries by organizationId, closing the
  cross-org IDOR where a member of one org could read or overwrite
  another org's project secrets (+ idor tests)
- deploy: require a base64 worker bundle in POST /v1/deploy and thread
  it client → CLI → provisioner instead of uploading an empty module;
  400 on missing/malformed bundle
- studio: replace try/finally + throw-in-try with promise combinators
  in Login/Invitations/Secrets forms so React Compiler can memoize them

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): add consolidated gap analysis and build plan

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): blue/green releases with health gating and rollback

Every deployment now uploads an immutable versioned script
({alias}-v{n}); the project's stable URL follows an active-deployment
pointer that only swaps after the new script passes a health probe, so
a bad deploy never replaces a serving one (gaps.md a1). Adds
POST /v1/deployments/rollback + lunora rollback (pointer swap back to a
retained superseded release), GET /v1/tenants/route + a cached alias
resolver in the dispatcher, per-phase deployment timestamps (a2), and
an x-lunora-id debug header (b3).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): aggregate spend caps with org suspension

Per-invocation limits cap one request; nothing capped aggregate period
spend (gaps.md c1). Adds a pure spend evaluator at the wfp cost basis
with per-plan default caps (org-overridable; explicit 0 = uncapped), an
hourly enforcement cron that suspends breaching orgs and self-heals
recovered ones, and dispatcher enforcement — a suspended org's tenants
serve 503 via the sentinel plan carried through the existing cache.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): custom domains — model, txt verification, hostname routing

First slice of gaps.md b1: the domains table (unique hostname, per-org
project scoping, redirect-only rows, cloudflare custom-hostname id),
add/list/remove/markVerified functions with the same authz gates as
secrets, a pure dns-over-https verification core (_lunora txt token +
platform cname check, injectable resolver), and routeForHostname — the
dispatcher-facing lookup that only ever routes verified domains to the
project's active script. Edge routes + dispatcher wiring land next.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): wire custom domains through edge and dispatcher

Completes the code-tractable half of gaps.md b1: POST /v1/domains (add,
returns the txt record to create), POST /v1/domains/verify (dns-over-
https txt + cname checks under the caller's session, outcome recorded
via markVerified), GET /v1/tenants/custom-domain for the dispatcher,
and a cached custom-domain resolver in the dispatcher that routes
verified hostnames to the project's active script and answers
redirect-only rows directly. cloudflare-for-saas cert provisioning
remains the 🌐 half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* style(cloud): hoist the trailing-dot regex to module scope

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): server-side builds, build logs, and push-to-deploy webhook

gaps.md a3/a4: builds table with a stale-recoverable work lease and
commit-sha dedup (a repeat push reuses the successful build's bundle
hash instead of rebuilding), streamed line-per-row build logs with a
cursor-paginated tail query, github app installations linked by account
slug, push + installation webhook parsing (default-branch pushes only,
zero-sha deletes ignored) wired through the hmac-verified edge route,
and a pure build-runner orchestration (claim → fetch → execute →
complete/fail) whose tarball-fetch and container-execute ports are the
remaining 🌐 half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant log ingestion and org right-to-erasure

gaps.md b2 + d3. logs: a tenantLogs ledger fed by the tail worker via
deploy-key-gated POST /v1/logs/ingest (batch + line-length caps, lines
truncated rather than dropped), a cursor-paginated member tail query,
and a 6-hourly retention prune (48h window). erasure: owners request
org deletion (30-day reversible window); the purge cron then erases
every org-scoped control-plane row, marks deployments destroyed for
the provisioner teardown path, and removes the org.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): mark shipped gaps in the build plan

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): dunning state machine and residency-aware cell placement

gaps.md c2 + f. dunning: a pure evaluator (payment failure → 14-day
grace anchored at first failure → suspend; any active/trialing
subscription rescues) driven by a 6-hourly cron over the synced
subscription states. suspensions now carry a reason so the spend-cap
and dunning crons only lift their own. placement: organizations.create
accepts a jurisdiction ("eu"/"fedramp") and picks a matching active
cell when no explicit cellId is given.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): lunora eject core — the no-lock-in exit hatch

gaps.md d2: a pure eject flow that pulls the tenant's full data
snapshot through its admin export api, scaffolds the byo wrangler.jsonc
the project would have had outside the platform (do bindings, d1
placeholder, sqlite migrations), and writes a restore readme — all over
injected ports so the packaging is fully unit-tested; the cli wires the
real i/o.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* style(cloud): use a template literal in the eject scaffold

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): studio tabs for domains, builds, and runtime logs

wires the round-7 backends into the hosted studio: a domains tab (add →
txt record callout → verify → live verified badge, remove), a builds
tab (per-project build list with live streamed output), and a logs tab
(deployment picker over a live runtime-log tail). marks c2/d2/f shipped
in the gap plan.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): fat-vs-thin runtime spike + fleet re-release pipeline

gaps.md e4 (the plan's ⭐ decide-now item). spike package
(spikes/runtime-versioning): the analysis — user functions execute
inside ShardDO and workerd has no dynamic code loading, so true-thin is
a distributed-transaction redesign, not a packaging change — plus live
probes for the three deciding hypotheses (cross-script DO bindings
under wfp, callback per-hop cost vs a 1ms viability line, fat-path
patch throughput arithmetic). provisional call: fat + pinned runtime +
automated forced re-release. that pipeline ships here too:
deployments record their runtimeVersion, and src/fleet/upgrade.ts
plans canary-first batches and halts on a dirty canary or breached
failure rate — a runtime patch becomes a paced batch job over the
existing build + health-gated release machinery.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): ring-2 pass — harden, close seams, finish product edges

security: github installations move to a staged-claim model (webhook
stages, owner/admin claims; recordPush only accepts claimed
installations and caps in-flight builds), domains.add enforces the
customDomains entitlement, and audit coverage lands for domains,
rollback, deletion requests, installation claims, and both suspension
crons. seams: build → deploy handoff via the runner's release port
(failed release keeps the artifact), stale-build self-healing cron,
superseded-release pruning (retain 3/project), and server-built pr
previews through the same pipeline. product: per-environment secrets
(all/production/preview/dev with kind-over-shared resolution + studio
picker), rollback button, suspension/deletion banners, org rename,
member role change (last-owner protected), project rename.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): switch billing to creem as merchant of record

Resolves gaps.md c3: creem (via @lunora/payment/creem) replaces the
stripe adapter as the platform's payment provider. As a merchant of
record it is the legal seller and calculates/collects/remits sales
tax/vat across 190+ jurisdictions, so the platform never inherits
worldwide tax compliance. Swaps the adapter wiring in the shard config
(CREEM_API_KEY / CREEM_WEBHOOK_SECRET / CREEM_TEST_MODE for the
sandbox), the webhook route + action to the creem-signature header,
the studio copy to creem product ids and hosted portal, and the docs.
Entitlements, dunning, plans, and quota enforcement are unchanged —
they ride the provider-agnostic subscriptions store.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): prepaid-credits overage billing core for creem

Verified against creem sdk 1.5.3: products are recurring/onetime only —
no metered subscription pricing — but creem ships a first-party credits
ledger (per-customer accounts, idempotent credit/debit by reference)
built for api metering. Overage is therefore prepaid: orgs buy credit
packs (one-time mor sales, tax handled by creem) and the platform
debits usage beyond the plan's included quota. Ships the pure core
(included quotas per plan, cost-plus overage rates, watermark-delta
debits with crash-safe idempotent references, exhausted → the existing
c1 suspension path, never negative), the overageDebits watermark table
with forward-only advancement, and 10 tests. The live credits api
wiring (CreditsLedgerPort) is the remaining 🌐 piece.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): creem credits-ledger adapter and fleet overage reconciliation

Completes the api/token-metering implementation over creem's
customerCredits api: a structural ledger adapter (balance reads via
bigint-safe strings, debits with the idempotent watermark reference,
missing account → null and never debitable), applyCreditPurchase for
the billing webhook (first purchase creates the account seeded with the
pack; later ones credit with the payment id as reference), the
organizations.creditsAccountId linkage (never overwritten once set),
and reconcileAllOverages — the fleet driver with per-org failure
isolation, watermark-advance strictly after a successful debit, and
exhausted balances handed to the c1 suspension hook.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): studio ux pass — usage meters, daily chart, command palette

Ring 3, patterns from the maple teardown (fsl-licensed observability
platform — ideas only, all code our own). usage tab: included-vs-used
plan-quota meters (amber at 80%, red past allowance, honest prepaid-
credits overage label) + a per-day request-volume chart over the new
usage.series query, rendered with a zero-dependency svg bar chart.
adds a ⌘k command palette (tab navigation + actions, substring match,
arrow/enter/escape keyboard flow, state reset by remount) wired into
the org dashboard. gaps.md gains the ranked ring-3 backlog (alerting
pillar, health charts, log-viewer upgrade, design tokens, onboarding
checklist, mcp surface, integrations hub).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): keep cron triggers within cloudflare cap

The control-plane Worker declared 4 distinct cron expressions (0 */1, 0
*/6, 0 */12, */1) — one over Cloudflare's hard limit of 3 Cron Triggers
per Worker, which would reject the deploy.

The lone 0 */12 trigger existed solely for "purge deleted organizations".
Fold that job into the existing 6h bucket: the purge gates on each org's
own 30-day retention cutoff, so a tighter cadence only shortens erasure
latency — it never erases early. Codegen drops the 0 */12 trigger, leaving
exactly 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01423xZDDqzhQ5D79vy25huF

* feat(cloud): observability ingest pipeline — issues + incidents (Phase 3) (#140)

* feat(cloud): add the observability ingest pipeline (issues + incidents)

Phase 3 of the observability plan — durable, cross-deployment monitoring
in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport.

- ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant
  `otlpSink` and the container exporter, decodes the error spans
  (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents
  through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous
  — the cloud app has no queue producer binding, so ingest inserts to D1
  directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the
  plaintext admin token.
- store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with
  `@lunora/fingerprint` (the same hash the local Studio computes, so a local
  Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts`
  member-authorized read/triage functions. A `TelemetryStore` adapter
  (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a
  guarded Pipeline→R2 archive, each a no-op without its binding.
- dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the
  `logStreams` entitlement, wired into `OrganizationDashboard`.
- bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket.

Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on
the cloud branch; the graft folds away once Phase 1 lands on alpha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* feat(cloud): observability alerts — rules + delivery (Phase 4) (#141)

* feat(cloud): add observability alerts — rules, firing + delivery

Phase 4 of the observability plan (the "watches while you sleep" tier),
stacked on the Phase 3 ingest.

- schema: `alertRules` (name, target issue/incident, threshold, channel
  email/webhook, destination, enabled) + `alerts` (fired-alert audit trail
  with firing→delivered state, notification denormalized).
- firing: the telemetry `ingest` mutation loads the org's enabled rules and
  fires each the first time a source's count crosses its threshold
  (`before < threshold <= after`, so exactly once), inserting a `firing`
  alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts`
  (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`.
- delivery: the `/v1/telemetry` edge handler delivers fired alerts
  best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps
  them delivered — never blocking or failing ingest.
- functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list,
  markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered).
- dashboard: `AlertsSection` (manage rules + recent fired alerts), gated
  behind the `logStreams` entitlement, wired into `OrganizationDashboard`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* fix(cloud): validate webhook alert destinations against SSRF

An alert rule's webhook `destination` is `fetch`ed by the control plane
when the alert fires, so an owner/admin could otherwise aim it at internal
infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) —
server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https
only, public host, no embedded credentials, no loopback/private/link-local
IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in
`deliverAlert` (never fetch an unsafe target — defense in depth for any
rule created before this guard). String-level, so it can't defeat DNS
rebinding, but it blocks the direct-address cases. Unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cloud): harden webhook SSRF guard

Two SSRF gaps in the Observability alert delivery path:

- deliverAlert followed webhook redirects, so a destination that passes
  isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the
  metadata IP). Set redirect: "manual" and reject 3xx responses.
- isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which
  the URL parser compresses to ::ffff:7f00:1) and the unspecified
  address (::) through. Reject the whole ::-prefixed non-global class.

Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already
blocked via WHATWG URL normalization; added as regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cloud): AI incident triage (@lunora/ai) — Phase 4C (#142)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(runtime): use LunoraError not undefined CirrusError in cloud endpoints

The scheduled-tick and queue-dispatch admin endpoints threw `new
CirrusError(...)`, a class that exists nowhere in the repo, so the file
failed to type-check (TS2304). The intended class is `LunoraError`, already
imported and used throughout the file.

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

* feat(cloud): full tenant log management with structured fields + trace correlation

Consume the structured, trace-correlated logs the framework now emits
(shared/log-event.ts) — the cloud log path kept only 3 severities + a flat
line and had no producer. Closes the framework side of Maple gap #2.

- Producer (GAPS.md B2, the missing piece): src/tail/worker.ts — the
  dispatch-namespace tail worker decodes each tenant `{source:"lunora",
  type:"log"}` console event (src/tail/parse.ts, pure + unit-tested), groups
  them per script, and POSTs batches to POST /v1/logs/tail. Holds one platform
  secret (LUNORA_TAIL_SECRET), not per-org deploy keys; the route resolves
  scriptName → org (logs.orgForScript) and stores via logs.ingestInternal.
  Deployed from tail.wrangler.jsonc.
- Store: tenantLogs widened to the full LogEvent shape — 7-tier severity,
  message, structured fields, functionPath, traceId/spanId, userId, shardKey —
  plus (scriptName, createdAt) and (org, traceId) indexes.
- Query: logs.list gained server-side levels/functionPath/traceId/search
  filters + a cursor and bounded limit, newest-first.
- UI: the studio Logs tab renders severity chips (filter), search, structured
  fields, and a short trace id per line.

Still 🌐: the provisioner setting tail_consumers on tenant scripts, an e2e run,
and correlating error/fatal lines to OTLP Issues by traceId (follow-up).

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

* fix(cloud): provision tenant bindings so deployed workers boot

The deploy handler built the provisioner spec with an empty binding set
(`bindings: {}`), so every uploaded tenant Worker was created with no
Durable Object binding and no `new_sqlite_classes` migration tag. A real
Lunora app always exports ShardDO, so it could never boot — the deploy
pipeline could only ship a binding-less worker.

The deploy request now carries the app's binding manifest (DO classes,
optional per-tenant D1/R2) which the CLI reads from `wrangler.jsonc`, and
the handler normalizes it to a spec that always includes the ShardDO
floor even when a caller under-declares or omits it. Malformed entries
are dropped and the DO list is capped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): tear down Cloudflare scripts for destroyed deployments

The lifecycle crons (cleanupExpiredPreviews, pruneSuperseded,
organizations.purgeDeleted) only transitioned a deployment to `destroyed`
— nothing ever deleted the Cloudflare dispatch script, so dispatch
namespaces grew unboundedly (the leak GAPS.md Ring-2 flagged as closed).

Add a `teardownAt` marker and a pure, port-injected `runTeardownSweep`
(per-target failure isolation, crash-safe idempotent off the marker),
wired into the control-plane Worker's scheduled() handler on the
hourly/6-hourly buckets — right after the crons that mark rows destroyed.
No-ops without Cloudflare credentials.

Per-tenant D1/R2 teardown-by-id still needs resource-id persistence and
is left as a follow-up; script deletion is the load-bearing fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): fold Analytics-Engine usage into the metering ledger

The dispatcher wrote one AE data point per tenant request, but nothing
ever read them back — createHttpAnalyticsReader had no caller, so
`platformUsage` only held what tenants self-report over POST /v1/usage
(nothing, in practice). Spend caps, the usage summary, and the usage
chart therefore evaluated an empty ledger.

Add a per-cell `usageReadAtMs` checkpoint and a pure, port-injected
`runUsageRollback` that delta-reads AE (`timestamp > checkpoint`),
attributes each dispatch script to its org/deployment, and appends
`requests` rows — then advances the checkpoint so re-runs never double
count. A per-row ledger failure is dropped rather than retried (under-
count, never double-bill — the same fail-safe as usage.rollup). Wired
into scheduled() on the hourly/6-hourly buckets; no-ops without
Cloudflare credentials.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): build-queue dispatcher (claim → run → drain)

`builds.claimNext` had no caller: enqueued builds sat untouched until the
24h expiry cron failed them with "no build runner picked this up". Add
the missing claim→run loop as a pure, port-injected `runBuildDispatch`
(bounded per-tick drain; a failed build never aborts the drain), fully
unit-tested against the runner ports.

Production activation stays gated on the runner's 🌐 seams — `execute`
(a throwaway Cloudflare Container running `lunora build`) and
`fetchSource` (GitHub App tarball) — which need live container infra, so
the dispatcher is not yet wired into scheduled(): claiming builds with no
executor would only burn them. This lands the verified logic so the
remaining work is purely the container seam, not the orchestration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* docs(cloud): split GAPS legend into wired vs pure-module (🧩)

The single ✅ conflated "tested pure function exists" with "feature runs".
Add a 🧩 marker for tested-but-uncalled modules, a dated wiring-pass
section covering the four gaps just addressed, and correct the two most
misleading inline entries:

- A3 builds: the claim dispatcher now exists (was missing); only the
  container execute() seam remains 🌐.
- C3 overage credits: reconcileAllOverages / applyCreditPurchase have no
  production caller (verified) — scheduling + webhook mapping are code
  (🔨), not credentials (🌐), so the honest status is 🧩, not "✅ core".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): boot-time route classification scanner

Port of Openship's route-scanner idea (Apache-2.0) to the /v1 router. The
control-plane routes each did inline auth then delegated to a self-
authorizing function, but nothing forced a *new* route to be classified —
an unclassified endpoint could ship silently and read as protected.

Every route now carries an explicit RouteSpec.auth (deployKey / session /
webhookHmac / tailSecret / adminToken / public), and
assertRoutesClassified runs when createDeployRouter builds the table: a
missing/unknown classification, a public route with no reason, or a
duplicate (method, path) throws at construction — the Worker fails to
start rather than serving an unclassified route. The flat dispatch tables
are derived from the one checked list (GET + POST unified). The spec's
opt-in `mcp` field is the allowlist the MCP surface will read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): MCP surface generated from the route registry

Port of Openship's "MCP tools derived from the route registry" idea
(Apache-2.0). A `/v1/mcp` JSON-RPC endpoint (tools/list + tools/call)
exposes only routes that opt in via RouteSpec.mcp, and every tool call
dispatches back through the real router carrying the agent's own bearer
credential — so it runs the identical auth + rate-limit + handler +
function-authz path as any HTTP caller; the MCP layer grants no privilege.

A hard deny-list (buildMcpTools) guarantees token/secret/tenant-access
routes (/v1/secrets, /v1/admin, /v1/invitations/send, /v1/logs/tail) and
the surface itself (/v1/mcp) can never become tools even if mis-annotated
— the same scope-escape guard Openship applies to tokens/auth/mcp. Only
bearer-callable (deployKey/adminToken) opted-in routes are eligible;
session/webhook routes are excluded. deployments.rollback is the first
tool exposed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* chore(codegen): regenerate _generated for teardownAt + usageReadAtMs

Keeps the emitted dataModel/shard/drizzle types consistent with the new
deployments.teardownAt and cells.usageReadAtMs schema columns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): tear down tenant D1 + R2, and test the sweep glue

Extends resource teardown past the dispatch script (#2). The lifecycle
sweep now also deletes the per-tenant D1 database and R2 bucket, resolved
by the same naming convention the provisioner creates them under (shared
tenantD1Name / tenantR2Bucket helpers — no drift, no new persistence).
New CF API methods: findD1DatabaseByName + deleteD1Database (uuid) and
deleteR2Bucket (name). Script + D1 delete are retryable; R2 is best-effort
(a non-empty bucket needs an S3-API object purge the teardown context
lacks — logged, left for follow-up). D1 (every .global() app has one) and
empty R2 buckets are now fully reclaimed.

Also extracts the scheduled() sweep glue into testable port-builders
(#4): teardownPorts + usageRollbackPorts over a structural ControlPlaneDb,
so the row→target mapping, the teardownAt stamp, the ledger insert, and
the per-cell checkpoint are unit-tested against a fake store instead of
living untested inside server.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): carry cronSpecs + bindings from wrangler on deploy

The cron fan-out read live deployments' cronSpecs, but nothing ever
populated them: deployments.create accepted the field yet the deploy
handler/router never passed it, so readCronTargets always returned [] and
the entire §2.4 tenant-cron fan-out had no data source (#3).

Add parseWranglerManifest — a pure reader that extracts the binding
manifest (DO classes / D1 / R2) and cron expressions from a tenant's
wrangler.jsonc — and thread cronSpecs through the deploy request →
handler → create mutation. The deploy client + CLI now forward both
bindings and cronSpecs, so a real deploy provisions what the Worker needs
and registers the crons the fan-out drives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): synthetic uptime monitoring with alerting

Adds external-vantage uptime to the control plane's Observability tier — the
piece a deployment can't self-report (if it's down, it can't say so).

- Probe: pure probeDeployment (generalizes the deploy-time healthCheck — GET,
  sub-500 = up, latency + timeout, never throws), a consecutive-failure state
  machine, and a summarizer, all unit-tested (src/uptime/probe.ts).
- Sweep: runUptimeSweep over injected ControlPlaneDb ports (mirroring the
  teardown/usage sweeps) probes every live deployment, records a uptimeChecks
  row, advances uptimeState, and fires an "uptime" alert the first time a
  deployment's failures cross a rule threshold — reusing crossesThreshold,
  renderAlert, and the alerts table/delivery pipeline (src/uptime/sweep.ts).
- Edge: server.ts scheduled() runs the sweep on the every-minute tick and
  delivers fired alerts over their channel (webhook/email), stamping the outcome.
- Alerts gain an "uptime" target (schema + createRule + renderAlert), so users
  configure "page me when my deployment is down" alongside issue/incident rules.
- Read side: lunora/uptime.ts (summary + recent queries, retention prune cron)
  backs a new Uptime dashboard section.

Cron triggers stay at 3 expressions (prune rides the 6h bucket, the probe rides
the existing every-minute tick). Full suite: 272 tests, lint:types clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019M6G6CAoLVrQMxDYg7BWq2

* fix(cloud): address thermo review of uptime monitoring

Security/correctness (branch audit):
- SSRF: th…
prisis added a commit that referenced this pull request Aug 2, 2026
…e 3) (#140)

* feat(cloud): add the observability ingest pipeline (issues + incidents)

Phase 3 of the observability plan — durable, cross-deployment monitoring
in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport.

- ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant
  `otlpSink` and the container exporter, decodes the error spans
  (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents
  through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous
  — the cloud app has no queue producer binding, so ingest inserts to D1
  directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the
  plaintext admin token.
- store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with
  `@lunora/fingerprint` (the same hash the local Studio computes, so a local
  Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts`
  member-authorized read/triage functions. A `TelemetryStore` adapter
  (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a
  guarded Pipeline→R2 archive, each a no-op without its binding.
- dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the
  `logStreams` entitlement, wired into `OrganizationDashboard`.
- bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket.

Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on
the cloud branch; the graft folds away once Phase 1 lands on alpha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* feat(cloud): observability alerts — rules + delivery (Phase 4) (#141)

* feat(cloud): add observability alerts — rules, firing + delivery

Phase 4 of the observability plan (the "watches while you sleep" tier),
stacked on the Phase 3 ingest.

- schema: `alertRules` (name, target issue/incident, threshold, channel
  email/webhook, destination, enabled) + `alerts` (fired-alert audit trail
  with firing→delivered state, notification denormalized).
- firing: the telemetry `ingest` mutation loads the org's enabled rules and
  fires each the first time a source's count crosses its threshold
  (`before < threshold <= after`, so exactly once), inserting a `firing`
  alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts`
  (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`.
- delivery: the `/v1/telemetry` edge handler delivers fired alerts
  best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps
  them delivered — never blocking or failing ingest.
- functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list,
  markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered).
- dashboard: `AlertsSection` (manage rules + recent fired alerts), gated
  behind the `logStreams` entitlement, wired into `OrganizationDashboard`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* fix(cloud): validate webhook alert destinations against SSRF

An alert rule's webhook `destination` is `fetch`ed by the control plane
when the alert fires, so an owner/admin could otherwise aim it at internal
infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) —
server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https
only, public host, no embedded credentials, no loopback/private/link-local
IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in
`deliverAlert` (never fetch an unsafe target — defense in depth for any
rule created before this guard). String-level, so it can't defeat DNS
rebinding, but it blocks the direct-address cases. Unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cloud): harden webhook SSRF guard

Two SSRF gaps in the Observability alert delivery path:

- deliverAlert followed webhook redirects, so a destination that passes
  isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the
  metadata IP). Set redirect: "manual" and reject 3xx responses.
- isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which
  the URL parser compresses to ::ffff:7f00:1) and the unspecified
  address (::) through. Reject the whole ::-prefixed non-global class.

Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already
blocked via WHATWG URL normalization; added as regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
prisis added a commit that referenced this pull request Aug 2, 2026
…rts, sessions, dashboards (#179)

* feat(studio): browse the durable log archive in the Logs panel (#155)

* feat(studio): browse the durable log archive in the Logs panel

Add a third "Archive" feed to the studio Logs panel that reads the durable
ctx.log archive pipelineLogSink writes to R2 (Iceberg / R2 Data Catalog).

- @lunora/runtime: a new admin-gated `/_lunora/admin/logs/archive` route
  (`log-archive-admin-routes.ts`) runs `createPipelineLogReader` server-side —
  the R2 SQL token stays on the worker, only decoded `{ rows, nextCursor }`
  reaches the browser. Reads creds from env (`R2_SQL_*`, `CLOUDFLARE_ACCOUNT_ID`
  fallback) + the table from a new `logArchive` WorkerOption. Fails closed with
  `LOG_ARCHIVE_NOT_CONFIGURED` when unwired.
- @lunora/client: `queryLogArchive(query)` method + re-exported PipelineLog*
  wire types (owned by @lunora/runtime).
- @lunora/studio: a self-contained `ArchiveFeed` (function/user/min-level
  filters, keyset "Load more", a "not configured" empty state distinct from an
  error) rendered under the new Archive tab; `errorCode` helper.
- Docs + API snapshots updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* refactor(studio): drop try/finally in ArchiveFeed for React Compiler

The React Compiler bails on a `try` with a `finally` clause (React Doctor
`react-hooks-js/todo`), so the component missed automatic memoization. Reset
`loading` in each branch instead, matching the repo's no-`finally` pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* refactor(studio): address thermos review of the archive feed

Code-quality + correctness follow-ups from the two-pass review:

- Extract the duplicated LEVEL_VARIANT map into `log-level-variant.ts`, shared by
  the live Logs feeds and the Archive feed (restores the exhaustive
  `Record<LogLevel, BadgeVariant>` type — drops the `?? "outline"` fallback).
- Collapse the four `view !== "archive"` readout guards in logs-panel into one
  `view === "archive" ? <ArchiveFeed/> : <>…</>` branch.
- Drop the `JSON.parse(JSON.stringify(baseQuery))` round-trip in the fetch effect
  — pass `baseQuery` directly, keying the effect on `querySignature`.
- Guard `loadMore` against a cross-filter race: a page-2 fetch that resolves
  after a filter change is dropped (via `activeSignatureRef`) instead of
  appending stale rows / overwriting the cursor.
- Show a "Loading…" placeholder on the initial fetch instead of a blank panel.
- Type `minLevel` state as `"" | ContextLogLevel` (removes a cast).
- Move the `LOG_ARCHIVE_NOT_CONFIGURED` sentinel to `shared/log-archive.ts` so
  runtime and studio share one source of truth with no dependency edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* refactor(studio): address CodeRabbit review of the archive feed

- Fold ArchiveFeed's five fetch-related useState into one useReducer, so each
  fetch transition (loading / loaded / append / notConfigured / failed /
  pageFailed) is a single dispatched action (React Doctor prefer-useReducer).
- Use the imported `ChangeEvent` type instead of the `React.*` namespace, matching
  logs-panel.tsx.
- Gate the toolbar `LiveError` on `view !== "archive"` so the disabled Errors
  feed's live-connection state can't leak into the (WS-less) Archive tab.

The CodeRabbit "cast env to LogArchiveEnvironment" suggestion is intentionally
skipped: `env ?? {}` narrows to `{}`, which is assignable to the all-optional
LogArchiveEnvironment (lint:types is green), and eslint's
no-unnecessary-type-assertion rejects the cast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* polish(studio): clear prior error on a fresh archive fetch

Thermos re-review nice-to-have: the reducer's `loading` action now clears
`error`/`notConfigured` (matching the kv reducers' `submitStart`), so retrying
after a failure shows the loading placeholder instead of the stale error line.
Rows are kept, so paging / filter-change refetches don't blank the table.

(Kept `default: return state` to stay consistent with the existing kv reducers
rather than introduce a one-off `unreachable` exhaustiveness guard.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(release): @lunora/runtime@1.0.0-alpha.32 [skip ci]\n\n## @lunora/runtime [1.0.0-alpha.32](https://github.com/anolilab/lunora/compare/%40lunora%2Fruntime%401.0.0-alpha.31...%40lunora%2Fruntime%401.0.0-alpha.32) (2026-07-21)

* chore(release): @lunora/client@1.0.0-alpha.26 [skip ci]\n\n## @lunora/client [1.0.0-alpha.26](https://github.com/anolilab/lunora/compare/%40lunora%2Fclient%401.0.0-alpha.25...%40lunora%2Fclient%401.0.0-alpha.26) (2026-07-21)

* **@lunora/runtime:** upgraded to 1.0.0-alpha.32

* docs: add cirrus cloud platform plan

Reverses the managed-deploy-plane won't-do (VOID-TEARDOWN.md §0/§6,
CONVEX-PARITY.md #23) with a scoped managed tier: Workers for Platforms
data plane, Convex-shaped product model (teams/projects/prod+dev+preview
deployments), PartyKit-style managed-vs-BYO CLI split, and a phased
roadmap starting with remote-binding dev. Synthesized from a repo
inventory, a Convex Cloud teardown, and a GitHub/Cloudflare-primitives
survey.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: fold supabase platform teardown into cloud plan

Adds Supabase as a reference model: the OSS/proprietary cut line,
IS_PLATFORM single-codebase studio pattern, Branching 2.0 preview DX
(and its pain points to fix: empty branches, hourly branch billing
outside spend caps), the Management API + OAuth-apps growth channel,
and the structural cost advantage WfP gives over per-project VMs.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add wfp constraints, eject path, spike checklist

Gap review of the cloud plan: documents that cron triggers are
silently dropped for namespaced user Workers (with SchedulerDO
alarm-based fan-out mitigation), queue-consumer and send_email
verification items, KV account-limit multiplexing, EU jurisdiction
toggle, a cirrus-eject portability command built on existing
export/import RPCs, namespace-wide observability reuse, managed
backups + abuse controls in Phase 4, and a Phase 1 constraint spike.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: verify cloud plan claims against cloudflare docs

Fact-checked every Cloudflare claim in CLOUD-PLAN.md against the
official docs: WfP pricing, KV/D1/R2 account limits, DO/R2
jurisdictions vs D1 location hints, CF for SaaS hostname pricing, and
remote-bindings GA versions all confirmed. Adds three newly verified
constraints: no gradual deployments for user Workers (rollback must be
platform-side bundle re-upload), the 1200-req/5-min account API rate
limit on provisioning, and outbound-Worker TCP/DO interception
trade-offs.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add cell-based scaling architecture to cloud plan

Answers how the managed tier scales without hitting account limits or
risking platform-wide blocks: script-resident tenant state with lazy
inference-driven provisioning, multi-account cells with cell IDs baked
into identifiers from day one, a per-cell API token-bucket scheduler,
a tenancy graduation ladder up to managed-BYO and the Tenant API, and
abuse containment to keep tenant abuse from looking like platform
abuse.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: scope alchemy to cell bring-up, not tenant deploys

Records the provisioning-engine decision: the per-tenant deploy path
stays hand-rolled on cloudflare-typescript (control-plane DB as the
single source of truth, cell scheduler, progress events, rollback
artifacts); Alchemy (pre-1.0, v2 rewrite underway, no confirmed
dispatch-namespace resource) is a candidate only for low-cardinality
cell bring-up IaC, with Terraform/Pulumi as the fallback.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: correct alchemy facts (dispatch-namespace resource, v0.93)

Re-checked Alchemy against GitHub/npm: it is v0.93.12 (Apache-2.0) and
does ship a dispatch-namespace (Workers for Platforms) resource — my
earlier 'lacks a confirmed dispatch-namespace resource' was wrong and
'v0.9x' undersold it. Recommendation is unchanged (hand-roll the
per-tenant deploy, use Alchemy for cell bring-up) but now rests on the
real reason — source-of-truth shape and deploy-orchestration concerns,
not capability — with a re-evaluation trigger at a stable 1.x.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: adopt alchemy as the provisioning engine

Decision: Alchemy is the provisioning engine across cell bring-up,
per-tenant managed deploy, and BYO. Verified it ships DispatchNamespace/
Worker/D1/R2/DO resources plus a built-in D1StateStore and runs inside a
Worker (await alchemy(scope) -> finalize/destroy). Backing each tenant
scope with the control-plane D1 collapses the two-sources-of-truth
concern into one store. The per-cell rate-limit scheduler now paces
finalize() runs; bundling stays in the Vite pipeline; rollback re-
converges to a prior R2-retained bundle. Risk of a 0.x dependency
contained behind a @cirrus/provision adapter with cloudflare-typescript
as fallback; spike open items recorded.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: build on alchemy v2 (alchemy@next, 2.0.0-beta.55)

Per decision to start on the v2 line: target alchemy@next (verified
2.0.0-beta.55, Effect-based) to avoid a v1->v2 migration mid-build,
with v1 0.93.x as the named fallback. Records the trade-offs (beta
churn, Effect pulled into the control-plane tree, quarantined behind
the @cirrus/provision adapter) and turns the unverified v2 facts
(DispatchNamespace resource + D1/DO state store, confirmed on v1 only
since docs/CDN were unreachable) into hard Phase 1 spike gates with
v1 fallback per surface.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: commit fully to alchemy v2, drop v1 fallback

Remove the v1 (0.93.x) fallback hedging throughout: v2 (alchemy@next,
2.0.0-beta.55) is the engine outright. Spike gates remain but now
resolve via owned shims or upstream contributions rather than retreat
to v1; a hard unresolvable 'no' escalates the engine decision instead
of silently dual-tracking.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add forgotten must-haves + fleet runtime-versioning risk

Gap pass on the cloud plan. New risk #8 (the load-bearing one): the
Cirrus runtime is bundled into each tenant Worker, so a security patch
means redeploying the whole fleet unless the tenant Worker is made
'thin' against a central runtime — a fat-vs-thin decision that must be
made before Phase 1 since it shapes the bundle format, deploy API, and
vite emit. New section 7 collects launch-blocking gaps the plan had
assumed away: control-plane DB durability/DR, cross-cell disaster
recovery, secrets-at-rest + cell-token custody, frontend-hosting scope,
AUP + bill-shock/cryptomining controls, billing/MoR/tax, GDPR
processor/DPA/SOC2, account offboarding + right-to-erasure, platform
self-observability + status page, dispatcher canary, and a staging cell.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): scaffold control-plane app built on cirrus

First implementation step from CLOUD-PLAN.md: a new apps/cloud workspace
app that dogfoods Cirrus as the platform's own control-plane backend.

- cirrus/schema.ts: control-plane data model (cells, organizations,
  members, projects, deployments, deployKeys, auditLog), all .global()
  (D1) — the plan's 'Worker + D1' control plane.
- cirrus functions: organizations/projects/deployments/cells/deploy-keys
  (create/list/issue/updateStatus), with owner seeding + audit trail.
- src/server.ts: control-plane Worker entry wiring D1-backed global tables.
- src/provision.ts: the @cirrus/provision seam — the sole coupling to the
  Alchemy v2 engine (stub that rejects until the Phase 1 spike wires it).
- configs (package.json/tsconfig/project.json/wrangler.jsonc/eslint/vitest),
  README, and a provision test.

Verified: codegen clean (no advisories), eslint clean, tsc --noEmit
passes, vitest green.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add deploy-orchestration core

Builds the next control-plane layer on the scaffold's Provisioner seam,
all pure/testable (no live Cloudflare needed):

- token-bucket.ts: per-cell API budget (§2.5), models CF's 1200/5min
  account limit; deterministic + clock-injectable.
- scheduler.ts: CellScheduler paces/serializes provisioner work against
  the bucket with priority ordering + a concurrency cap.
- orchestrator.ts: runDeployment state machine emitting queued →
  provisioning → live/failed progress events (§2.2); destroyDeployment
  for preview-TTL/project teardown.
- keys.ts: deploy-key format/parse/hash helpers; deploy-keys.ts mutation
  refactored to use them (one tested place for the format + SHA-256).

17 tests across 5 files; eslint + tsc --noEmit clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add org authorization + deploy-key lifecycle

Closes gaps found reviewing the control plane:

- authz.ts: assertMember(ctx, orgId, roles?) — the org ACL gate. Every
  org-scoped function now verifies the caller is a member with a
  permitted role, closing an IDOR hole where any signed-in user could
  read/mutate any org by passing its id. Applied across projects,
  deployments, deploy-keys.
- members.ts: list / add / remove so memberships can actually be granted
  (owner is seeded on org create; admins/owners manage the rest).
- deploy-keys: verify (the deploy API's auth path — match by SHA-256,
  reject revoked, bump lastUsedAt, return the DB-authoritative target)
  and revoke (leaked-key mitigation); lastUsedAt/revokedAt are now live.
- deployments: create checks the project belongs to the org; updateStatus
  loads the deployment and gates on its org (documented as the system
  seam for the orchestrator).

codegen clean, eslint + tsc --noEmit clean, 17 tests pass.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): deploy API endpoint, deploy-key auth, handler tests

Lands the three remaining pieces together:

- Deploy API: POST /v1/deploy mounted via the httpRouter seam (src/deploy/
  router.ts) → pure handler (src/deploy/handler.ts) authenticates the bearer
  deploy key, records a queued deployment, drives runDeployment through the
  per-cell scheduler, and streams NDJSON progress (accepted→queued→
  provisioning→live/failed→done), patching status per phase.
- Auth path: investigation showed internalMutation is unreachable from the
  HTTP action-context dispatch (no system flag → RPC 404), so verify/
  updateStatus stay public; instead added deploy-key authorization
  (authz.authorizeDeployKey) and a dual-path (member session OR deploy key)
  on deployments.create/updateStatus, so CI deploys need no user session.
  Corrected the stale 'should become internalMutation' comments.
- Tests: handleDeployRequest (401/403/400 + success and failure streaming +
  status transitions) and authz (assertMember + authorizeDeployKey) via a
  fake ctx. 28 tests / 7 files; eslint + tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): refresh status — deploy API + auth now in place

* chore(cloud): track generated schema snapshot

Matches the apps/playground convention — .cirrus-schema.json is the
codegen schema snapshot used for migration/drift detection and is
committed, not gitignored.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): preview lifecycle, GitHub webhook, deploy client (Phase 1/2)

Phase 2:
- Preview deployments are TTL'd: deployments.create stamps expiresAt for
  kind=preview (src/deploy/preview.ts: deterministic previewScriptName +
  5-day previewExpiry); an hourly cron (cirrus/crons.ts -> internal
  deployments.cleanupExpiredPreviews) marks expired previews destroyed.
  Worker gains scheduled(); wrangler cron trigger added.
- GitHub webhook (src/github/webhook.ts): HMAC-SHA256 verify + pull_request
  -> preview-intent parsing, mounted at POST /v1/github/webhook.

Phase 1:
- Deploy client (src/deploy/client.ts): the cirrus-deploy core — POSTs to
  /v1/deploy and consumes the NDJSON progress stream.

13 new tests (41 total); codegen/eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: update roadmap — Phase 0 shipped, Phases 1-2 status

Phase 0 (remote-binding dev) is already implemented in the framework
(@cirrus/config remote-bindings + @cirrus/vite plugin + cirrus dev;
30 tests) — corrected from 'not started'. Phases 1-2 marked
substantially-built with the live-Cloudflare-dependent remainder
(Alchemy provisioner, dispatcher, e2e validation) called out.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): team invitations + billing quotas; repo-extraction guide (Phase 3/4)

Phase 3 (hosted studio sliver):
- Team invitations (cirrus/invitations.ts + invitations table): invite/list/
  revoke/accept, single-use SHA-256-hashed tokens (plaintext mailed once),
  owner-admin gated; accept-by-token adds the caller as a member.

Phase 4 (billing sliver):
- Plans + quota entitlements (src/billing/plans.ts) on @cirrus/payment's
  entitlements model — free/pro/enterprise limits + feature flags, with
  effectiveLimit/withinQuota and a free-tier fallback for non-subscribers.

Portability (move to a private repo):
- EXTRACT.md documents the mechanical extraction; audit confirms the app
  imports only published @cirrus/* entry points (no monorepo-internal reaches).

6 new tests (47 total); codegen/eslint/tsc clean; secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): real Cloudflare provisioner, quota enforcement, webhook project resolution

Provisioner (Phase 1 — the big one): replaces the rejecting stub with a real
implementation over a typed Cloudflare REST port (src/cloudflare/api.ts):
deploy provisions per-tenant D1/R2, uploads the user Worker into the dispatch
namespace with binding + DO-migration metadata, applies secrets, returns the
bundle hash + routed URL; destroy deletes the script. Port-injected so it's
tested with a fake; plug in CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN to run.
(REST via fetch rather than the unverifiable alchemy@next beta — same seam.)

Quota (Phase 4): plans.ts gains planLimit/withinPlanQuota; projects.create and
members.add enforce the org plan's limits.

Preview automation (Phase 2): projects gain githubRepo + byGithubRepo lookup;
the webhook resolves the connected project and returns the preview script name.

Env documented (.dev.vars.example + wrangler vars). 50 tests; eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): dispatcher Worker + hosted-studio admin-RPC proxy (Phase 1/3)

Phase 1 — dispatcher Worker (the request-path front door): resolveTenant
maps {scriptName}.{appDomain} (and custom domains via injected lookup) to a
dispatch-namespace script; the worker forwards via env.DISPATCHER.get with
per-plan limits. Separate deployable (dispatcher.wrangler.jsonc).

Phase 3 — admin-RPC proxy: proxyAdminRequest authorizes org membership,
forwards the admin RPC to the tenant's /_cirrus/admin/* with that
deployment's admin token, and records an audit entry. Pure (deps injected).

7 new tests (57 total); eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): cirrus login/link/deploy CLI commands (Phase 1)

Pure command logic over a ConfigStore + the deploy client: login persists the
API endpoint + deploy key, link binds a project, deploy streams a managed
deploy (requires login+link). File-backed store at ~/.cirrus/cloud.json for
the Node CLI; cerebro registration in @cirrus/cli calls these.

3 new tests (60 total); eslint/tsc clean; secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: roadmap status — control-plane backend feature-complete as code

All phases' backend code is built + unit-tested in apps/cloud (60 tests):
real REST provisioner, dispatcher, CLI, preview lifecycle, GitHub webhook,
team invitations, admin-RPC proxy, quota enforcement. Remaining items are
the ones needing live Cloudflare / external services / the studio UI.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): admin-proxy live wiring, usage metering, custom-hostname port (Phase 3/4)

Phase 3 — admin proxy mounted at POST /v1/admin: deployments now carry the
platform-minted tenant adminToken (set as the worker's CIRRUS_ADMIN_TOKEN
secret + stored on the row), deployments.adminTarget resolves {url, adminToken}
after asserting membership, and the router forwards to the tenant's
/_cirrus/admin/* with an audit-log.record entry.

Phase 4 — usage metering: usageEvents table + internal record mutation +
member summary query over a pure aggregateUsage roll-up. Custom hostnames:
CloudflareApi.createCustomHostname (Cloudflare for SaaS, zone-scoped REST).

Router refactored into per-route handlers. 64 tests; eslint/tsc/secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: roadmap — admin proxy mounted, usage metering + custom-hostname port added

* feat(cloud): add hosted studio react spa

Build the hosted-studio frontend for the Cirrus Cloud control plane: a
better-auth-gated React SPA served on one origin with the control-plane
Worker via @cirrus/vite.

- src/client: main/auth-client/Login, App auth gate, OrganizationList,
  OrganizationDashboard with tabs for projects, deployments, members,
  deploy keys, invitations, and usage; AsyncList loading/empty helper.
- Wire @cirrus/auth into src/server.ts (createAuth + cirrusD1Adapter,
  ensureMigrated, handleAuthRequest, authAdmin, resolveIdentity) and add
  AUTH_SECRET/AUTH_URL env + .dev.vars.example entries.
- Switch package scripts to vite (build/dev), add react/react-dom +
  @cirrus/react/@cirrus/auth deps, vite.config.ts, index.html, and the
  DOM lib + jsx in tsconfig.
- eslint: client section (filename-case, react-perf, void), browser
  globals; ignore vite.config.ts.
- Refresh README + CLOUD-PLAN status to reflect the studio UI.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add billing, metering, and hardened auth

Billing on @cirrus/payment (§4): org id is the payment referenceId. Wire a
Stripe adapter into createShardDO({ payment }); add cirrus/billing.ts with
checkout/portal actions, entitlements/subscription reads (resolved through
CIRRUS_CLOUD_PLANS with a free-tier fallback), and a signature-verified
processWebhook mounted at POST /v1/billing/webhook. The studio gains a
Billing tab.

Platform metering (§4): rename the resource-metering table to platformUsage
(freeing usageEvents for @cirrus/payment's billing ledger), add a deploy-key
authenticated usage.ingest mutation + POST /v1/usage endpoint, and enforce
per-plan runtime limits in the dispatcher (limitsForPlan → DISPATCHER.get).

Auth hardening (§3) on @cirrus/auth/better-auth: mail-backed email
verification + password reset (@cirrus/mail), optional GitHub/Google OAuth,
admin/twoFactor/passkey plugins, built-in auth rate limiting, plus a per-IP
@cirrus/ratelimit cap on the /v1/* surface. Invitations now email the token
via POST /v1/invitations/send (never shown in the browser). The Cirrus
organizations/members model stays the single org source of truth (better-auth
organization plugin deliberately omitted).

Add deps (@cirrus/mail, @cirrus/ratelimit, stripe), tests for the router
routes + rate limiting + per-plan limits (69 total), and reconcile the README
+ CLOUD-PLAN status (the provisioner is a real Cloudflare REST impl, not a
stub).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): enforce entitlements, wire metering source, add secrets

Close the billing loose ends and add the metering source, tenant secrets,
and an audit-log view.

Entitlements (close loose end #1): quota is now enforced against live
subscription state (cirrus/entitlements.ts resolves from the synced
`subscriptions` table) rather than the static organizations.plan column —
projects/members creation call assertWithinQuota, so a Stripe upgrade raises
limits immediately with no column to sync.

Per-plan dispatch limits (close loose end #2): deployments.planForScript +
a bearer-gated GET /v1/tenants/plan endpoint + a cached plan resolver in the
dispatcher (createPlanResolver) wire resolvePlan, so runtime limits actually
scale per plan instead of always falling back to free.

Metering source: the dispatcher emits one Analytics Engine data point per
tenant request (src/metering/analytics.ts); a reader port + HTTP impl and an
hourly usage.rollup compaction cron complete the pipeline alongside the
existing /v1/usage ledger ingest.

Tenant secrets (§7): AES-256-GCM envelope encryption at the edge
(src/secrets/crypto.ts), a secrets table (ciphertext + IV only), store/list/
listEncrypted/remove functions, POST /v1/secrets, deploy-time materialization
into the tenant Worker, and a studio Secrets tab.

Studio: add Secrets + Activity (audit log) tabs; add audit-log.list.

Tests: crypto round-trip, plan resolver caching/fallback, entitlement quota,
analytics writer/reader (83 total). Docs + .dev.vars.example updated.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): address review findings (rollup atomicity, deploy failure, dedup)

Apply /review findings on the recent billing/metering/secrets work:

Correctness:
- usage.rollup: the D1 global backend has no multi-statement transaction, so
  the old insert-summed-then-delete-originals order could double-count (over-
  bill) on a mid-rollup crash. Reorder to delete the extras first, then patch
  the surviving row's total last — a crash can now only under-count, never
  leave a summed row beside surviving originals.
- deploy handler: a tenant-secret decrypt failure (corrupt secret / rotated
  key) threw inside the NDJSON stream and left the deployment stuck in
  `accepted`. Catch it and transition to `failed` with a status update.
- POST /v1/secrets: encryption/config failures (e.g. a malformed
  SECRET_ENCRYPTION_KEY) now return 500, not a misleading 403 (kept distinct
  from the membership 403 the store mutation raises); reject the reserved
  CIRRUS_ADMIN_TOKEN secret name up front instead of silently clobbering it.
- studio: drop the plan picker from org creation — limits now come from live
  subscription entitlements, so selecting a paid plan at create-time granted
  nothing. Orgs start free; upgrade via the Billing tab.

Cleanup:
- Extract the cross-org IDOR guard into authz.assertRowInOrg and call it from
  secrets/members/deploy-keys/invitations (was four byte-identical copies).
- Remove dead plans.ts exports planLimit/withinPlanQuota (superseded by
  entitlements-based quota); add a single highestPlan/PLAN_PRECEDENCE helper
  and use it in deployments.planForScript (was a hand-rolled tier ladder).
- Memoize the Stripe payment config per isolate (was rebuilt on every shard
  request that touches ctx.payments).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* test(cloud): validate websockets through dispatch (phase 1 spike)

Validate the hottest path — hibernated-WS subscriptions + per-invocation
limits through env.DISPATCHER.get() — the least-documented WfP case (risk #3).

- spikes/ws-dispatch/: a runnable harness for live validation on a real
  dispatch namespace. A framework-free hibernatable-WebSocket Durable Object
  (the exact primitive ShardDO uses: acceptWebSocket + webSocketMessage),
  deployable into the namespace, plus a zero-dep Node probe that drives it
  through the dispatcher and asserts: (1) the WS upgrade survives the dispatch
  hop (101 + live socket), (2) a hibernated server push (broadcast) reaches the
  socket — the mutation-to-subscription shape, (3) cpuMs-limit behaviour. The
  README documents deploy/run, pass/fail, and the expected results + caveats.
- __tests__/dispatcher-ws.test.ts: unit-pins the dispatcher forwarding
  contract (returns the tenant 101+webSocket response unchanged, applies
  per-plan limits, meters the upgrade once) — runs in CI, no infra needed.
- dispatcher worker: clarifying comments on WS pass-through + per-frame
  metering semantics. CLOUD-PLAN risk #3 now references the harness.

The dispatcher half is verified here (94 tests); the end-to-end behaviour
needs a live Cloudflare account + the Workers-for-Platforms add-on to run.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant cron fan-out through dispatch (wfp workaround)

Cloudflare drops triggers.crons for Workers in a dispatch namespace, so tenant
cron jobs never fire. Fan them out from the control plane (CLOUD-PLAN §2.4).

- @cirrus/runtime: add an admin-gated POST /_cirrus/scheduled tick endpoint
  that runs a cron expression's jobs through the SAME handleScheduled path the
  native scheduled() trigger uses (user crons + code crons + backup), so a
  platform can drive a namespaced tenant's crons over HTTP. (Dispatch stubs
  expose only fetch()/connect() — no scheduled()/queue() — so HTTP is the only
  transport in.)
- src/fanout/cron.ts: pure 5-field cron-expression matching (lists, ranges,
  steps, dom/dow OR semantics) + dueTicks + fanOutCron orchestration.
- control plane: capture each tenant's cronSpecs on the deployments row at
  deploy; an every-minute heartbeat cron (cirrus/fanout.ts) makes codegen emit
  the */1 trigger, and server.ts scheduled() reads live cron targets and ticks
  each due tenant via env.DISPATCHER.get(script).fetch('/_cirrus/scheduled')
  with the per-deployment admin token (kept in-process — never exposed). Adds
  the DISPATCHER binding to the control-plane wrangler.

Tests: cron matching, dueTicks, fanOutCron (103 cloud tests; 337 runtime
tests still green). Live validation on a dispatch namespace pending; queue
consumer fan-out is the remaining half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant queue-consumer fan-out through dispatch (wfp workaround)

WfP namespaced Workers can't be queue consumers, so tenant queue-backed work
(@cirrus/mail sends, scheduler queue-workpool) never drains. Fan it out from a
platform-owned consumer (CLOUD-PLAN §2.4) — the queue counterpart to the cron
fan-out.

- @cirrus/runtime: add a `queueHandler` option + an admin-gated
  POST /_cirrus/queue endpoint that reconstructs the batch and invokes it,
  returning the message ids to retry. (Dispatch stubs are fetch-only, so HTTP
  is the only transport into a namespaced tenant.)
- src/fanout/queue.ts: pure grouping of a shared-queue batch by the producing
  tenant's script (envelope `{ script, body }`) + fan-out orchestration that
  collects per-message retries and retries a whole group on delivery failure.
- control plane: the account-level Worker is the consumer — server.ts queue()
  drains the shared cirrus-tenant-queue, resolves each tenant's admin token
  in-process (never exposed), forwards sub-batches via
  env.DISPATCHER.get(script).fetch('/_cirrus/queue'), and acks/retries per the
  tenant reply. Adds the queues.consumers binding to the control-plane wrangler.

Tests: groupByTenant + fanOutQueue (108 cloud tests; 337 runtime tests green).
Live validation on a dispatch namespace + a producer-side script-tagging helper
remain.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): align with the lunora rebrand + reuse @lunora/analytics

Rebased onto alpha, which renamed the framework cirrus → lunora. Reconcile the
control-plane app and reuse a newly-shipped package.

Rebrand:
- npm scope @cirrus/* → @lunora/* across deps + imports.
- app functions dir cirrus/ → lunora/ (+ tsconfig/eslint globs, _generated
  paths, the committed schema snapshot → .lunora-schema.json).
- reserved paths /_cirrus/* → /_lunora/* (incl. the new scheduled/queue tick
  endpoints) and the runtime-injected env.__lunoraCtx; renamed exported symbols
  (LunoraError, LunoraClient/Provider, useLunora, lunoraD1Adapter, LunoraAuth*,
  LUNORA_CRONS/FUNCTIONS); vite plugin cirrus() → lunora(); CLI config dir
  ~/.cirrus → ~/.lunora.
- wire the new required GlobalIntrospector.facetColumn via @lunora/d1's
  facetGlobalColumn.

Reuse:
- src/metering/analytics.ts is now a thin domain layer over @lunora/analytics
  (createAnalytics writeDataPoint + createAnalyticsSqlClient AE-SQL reader)
  instead of a hand-rolled writeDataPoint + HTTP SQL client.

Verified the rest is genuinely cloud-specific (cron-expression matching, AES-GCM
secret crypto, the Cloudflare REST provisioner, the per-cell CF-API token
bucket) — no upstream equivalent to fold into.

103→108 cloud tests green; runtime 379 tests green; tsc/eslint/build clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): rebrand the product Cirrus Cloud → Lunora Cloud

Complete the lunora rebrand to the product layer (the framework already moved):

- brand prose Cirrus Cloud → Lunora Cloud across code comments, README,
  EXTRACT, the studio (index.html title, Login/dashboard), and CLOUD-PLAN.md.
- env vars CIRRUS_* → LUNORA_*: LUNORA_ADMIN_TOKEN and LUNORA_MAIL_CAPTURE are
  functional (read by @lunora/mail); LUNORA_APP_DOMAIN / LUNORA_CELL and the
  VITE_LUNORA_URL client var follow for consistency.
- the LUNORA_CLOUD_PLANS entitlements constant.
- infra names cirrus-* → lunora-*: worker names (lunora-cloud, lunora-dispatcher),
  dispatch namespace (lunora-production), shared queue (lunora-tenant-queue),
  AE dataset (lunora_tenant_usage), the lunora.app apex, and the deploy
  dispatch-namespace prefix.
- the hosted-CLI verbs (lunora login/link/deploy) and config dir ~/.lunora.
- docs' reserved-path/marker refs (/_lunora/*, __lunora_admin__, env.__lunoraCtx).

108 cloud tests green; tsc/eslint/build clean; zero residual `cirrus` references.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* refactor(cloud): migrate functions to fluent builder api

Adapt the cloud control-plane functions to alpha v1.0.0-alpha.1's fluent
function builders: kind.input({...}).<terminal>(({ ctx, args }) => ...)
replaces the removed object form kind({ args, handler }). Regenerate
_generated/* and pick up codegen's observability block in wrangler.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): license under polyform noncommercial

The control plane is the proprietary product layer, so it must not carry
the framework's FSL-1.1-Apache-2.0 (which grants broad commercial rights).
Apply PolyForm Noncommercial 1.0.0: any noncommercial purpose is permitted,
but commercial use requires a separate license. Replaces UNLICENSED.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): adopt @lunora/bindings/analytics after package fold-in

The latest alpha folded @lunora/analytics into @lunora/bindings (subpath
export ./analytics, identical API) and codegen now emits
_generated/functions.ts importing @lunora/values directly. Swap the
dependency and import specifiers, declare @lunora/values, regenerate
_generated/*, and reconcile the lockfile.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): address pr review findings

- secrets: assert the project belongs to the org in store/list/
  listEncrypted and scope queries by organizationId, closing the
  cross-org IDOR where a member of one org could read or overwrite
  another org's project secrets (+ idor tests)
- deploy: require a base64 worker bundle in POST /v1/deploy and thread
  it client → CLI → provisioner instead of uploading an empty module;
  400 on missing/malformed bundle
- studio: replace try/finally + throw-in-try with promise combinators
  in Login/Invitations/Secrets forms so React Compiler can memoize them

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): add consolidated gap analysis and build plan

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): blue/green releases with health gating and rollback

Every deployment now uploads an immutable versioned script
({alias}-v{n}); the project's stable URL follows an active-deployment
pointer that only swaps after the new script passes a health probe, so
a bad deploy never replaces a serving one (gaps.md a1). Adds
POST /v1/deployments/rollback + lunora rollback (pointer swap back to a
retained superseded release), GET /v1/tenants/route + a cached alias
resolver in the dispatcher, per-phase deployment timestamps (a2), and
an x-lunora-id debug header (b3).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): aggregate spend caps with org suspension

Per-invocation limits cap one request; nothing capped aggregate period
spend (gaps.md c1). Adds a pure spend evaluator at the wfp cost basis
with per-plan default caps (org-overridable; explicit 0 = uncapped), an
hourly enforcement cron that suspends breaching orgs and self-heals
recovered ones, and dispatcher enforcement — a suspended org's tenants
serve 503 via the sentinel plan carried through the existing cache.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): custom domains — model, txt verification, hostname routing

First slice of gaps.md b1: the domains table (unique hostname, per-org
project scoping, redirect-only rows, cloudflare custom-hostname id),
add/list/remove/markVerified functions with the same authz gates as
secrets, a pure dns-over-https verification core (_lunora txt token +
platform cname check, injectable resolver), and routeForHostname — the
dispatcher-facing lookup that only ever routes verified domains to the
project's active script. Edge routes + dispatcher wiring land next.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): wire custom domains through edge and dispatcher

Completes the code-tractable half of gaps.md b1: POST /v1/domains (add,
returns the txt record to create), POST /v1/domains/verify (dns-over-
https txt + cname checks under the caller's session, outcome recorded
via markVerified), GET /v1/tenants/custom-domain for the dispatcher,
and a cached custom-domain resolver in the dispatcher that routes
verified hostnames to the project's active script and answers
redirect-only rows directly. cloudflare-for-saas cert provisioning
remains the 🌐 half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* style(cloud): hoist the trailing-dot regex to module scope

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): server-side builds, build logs, and push-to-deploy webhook

gaps.md a3/a4: builds table with a stale-recoverable work lease and
commit-sha dedup (a repeat push reuses the successful build's bundle
hash instead of rebuilding), streamed line-per-row build logs with a
cursor-paginated tail query, github app installations linked by account
slug, push + installation webhook parsing (default-branch pushes only,
zero-sha deletes ignored) wired through the hmac-verified edge route,
and a pure build-runner orchestration (claim → fetch → execute →
complete/fail) whose tarball-fetch and container-execute ports are the
remaining 🌐 half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant log ingestion and org right-to-erasure

gaps.md b2 + d3. logs: a tenantLogs ledger fed by the tail worker via
deploy-key-gated POST /v1/logs/ingest (batch + line-length caps, lines
truncated rather than dropped), a cursor-paginated member tail query,
and a 6-hourly retention prune (48h window). erasure: owners request
org deletion (30-day reversible window); the purge cron then erases
every org-scoped control-plane row, marks deployments destroyed for
the provisioner teardown path, and removes the org.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): mark shipped gaps in the build plan

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): dunning state machine and residency-aware cell placement

gaps.md c2 + f. dunning: a pure evaluator (payment failure → 14-day
grace anchored at first failure → suspend; any active/trialing
subscription rescues) driven by a 6-hourly cron over the synced
subscription states. suspensions now carry a reason so the spend-cap
and dunning crons only lift their own. placement: organizations.create
accepts a jurisdiction ("eu"/"fedramp") and picks a matching active
cell when no explicit cellId is given.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): lunora eject core — the no-lock-in exit hatch

gaps.md d2: a pure eject flow that pulls the tenant's full data
snapshot through its admin export api, scaffolds the byo wrangler.jsonc
the project would have had outside the platform (do bindings, d1
placeholder, sqlite migrations), and writes a restore readme — all over
injected ports so the packaging is fully unit-tested; the cli wires the
real i/o.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* style(cloud): use a template literal in the eject scaffold

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): studio tabs for domains, builds, and runtime logs

wires the round-7 backends into the hosted studio: a domains tab (add →
txt record callout → verify → live verified badge, remove), a builds
tab (per-project build list with live streamed output), and a logs tab
(deployment picker over a live runtime-log tail). marks c2/d2/f shipped
in the gap plan.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): fat-vs-thin runtime spike + fleet re-release pipeline

gaps.md e4 (the plan's ⭐ decide-now item). spike package
(spikes/runtime-versioning): the analysis — user functions execute
inside ShardDO and workerd has no dynamic code loading, so true-thin is
a distributed-transaction redesign, not a packaging change — plus live
probes for the three deciding hypotheses (cross-script DO bindings
under wfp, callback per-hop cost vs a 1ms viability line, fat-path
patch throughput arithmetic). provisional call: fat + pinned runtime +
automated forced re-release. that pipeline ships here too:
deployments record their runtimeVersion, and src/fleet/upgrade.ts
plans canary-first batches and halts on a dirty canary or breached
failure rate — a runtime patch becomes a paced batch job over the
existing build + health-gated release machinery.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): ring-2 pass — harden, close seams, finish product edges

security: github installations move to a staged-claim model (webhook
stages, owner/admin claims; recordPush only accepts claimed
installations and caps in-flight builds), domains.add enforces the
customDomains entitlement, and audit coverage lands for domains,
rollback, deletion requests, installation claims, and both suspension
crons. seams: build → deploy handoff via the runner's release port
(failed release keeps the artifact), stale-build self-healing cron,
superseded-release pruning (retain 3/project), and server-built pr
previews through the same pipeline. product: per-environment secrets
(all/production/preview/dev with kind-over-shared resolution + studio
picker), rollback button, suspension/deletion banners, org rename,
member role change (last-owner protected), project rename.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): switch billing to creem as merchant of record

Resolves gaps.md c3: creem (via @lunora/payment/creem) replaces the
stripe adapter as the platform's payment provider. As a merchant of
record it is the legal seller and calculates/collects/remits sales
tax/vat across 190+ jurisdictions, so the platform never inherits
worldwide tax compliance. Swaps the adapter wiring in the shard config
(CREEM_API_KEY / CREEM_WEBHOOK_SECRET / CREEM_TEST_MODE for the
sandbox), the webhook route + action to the creem-signature header,
the studio copy to creem product ids and hosted portal, and the docs.
Entitlements, dunning, plans, and quota enforcement are unchanged —
they ride the provider-agnostic subscriptions store.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): prepaid-credits overage billing core for creem

Verified against creem sdk 1.5.3: products are recurring/onetime only —
no metered subscription pricing — but creem ships a first-party credits
ledger (per-customer accounts, idempotent credit/debit by reference)
built for api metering. Overage is therefore prepaid: orgs buy credit
packs (one-time mor sales, tax handled by creem) and the platform
debits usage beyond the plan's included quota. Ships the pure core
(included quotas per plan, cost-plus overage rates, watermark-delta
debits with crash-safe idempotent references, exhausted → the existing
c1 suspension path, never negative), the overageDebits watermark table
with forward-only advancement, and 10 tests. The live credits api
wiring (CreditsLedgerPort) is the remaining 🌐 piece.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): creem credits-ledger adapter and fleet overage reconciliation

Completes the api/token-metering implementation over creem's
customerCredits api: a structural ledger adapter (balance reads via
bigint-safe strings, debits with the idempotent watermark reference,
missing account → null and never debitable), applyCreditPurchase for
the billing webhook (first purchase creates the account seeded with the
pack; later ones credit with the payment id as reference), the
organizations.creditsAccountId linkage (never overwritten once set),
and reconcileAllOverages — the fleet driver with per-org failure
isolation, watermark-advance strictly after a successful debit, and
exhausted balances handed to the c1 suspension hook.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): studio ux pass — usage meters, daily chart, command palette

Ring 3, patterns from the maple teardown (fsl-licensed observability
platform — ideas only, all code our own). usage tab: included-vs-used
plan-quota meters (amber at 80%, red past allowance, honest prepaid-
credits overage label) + a per-day request-volume chart over the new
usage.series query, rendered with a zero-dependency svg bar chart.
adds a ⌘k command palette (tab navigation + actions, substring match,
arrow/enter/escape keyboard flow, state reset by remount) wired into
the org dashboard. gaps.md gains the ranked ring-3 backlog (alerting
pillar, health charts, log-viewer upgrade, design tokens, onboarding
checklist, mcp surface, integrations hub).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): keep cron triggers within cloudflare cap

The control-plane Worker declared 4 distinct cron expressions (0 */1, 0
*/6, 0 */12, */1) — one over Cloudflare's hard limit of 3 Cron Triggers
per Worker, which would reject the deploy.

The lone 0 */12 trigger existed solely for "purge deleted organizations".
Fold that job into the existing 6h bucket: the purge gates on each org's
own 30-day retention cutoff, so a tighter cadence only shortens erasure
latency — it never erases early. Codegen drops the 0 */12 trigger, leaving
exactly 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01423xZDDqzhQ5D79vy25huF

* feat(cloud): observability ingest pipeline — issues + incidents (Phase 3) (#140)

* feat(cloud): add the observability ingest pipeline (issues + incidents)

Phase 3 of the observability plan — durable, cross-deployment monitoring
in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport.

- ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant
  `otlpSink` and the container exporter, decodes the error spans
  (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents
  through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous
  — the cloud app has no queue producer binding, so ingest inserts to D1
  directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the
  plaintext admin token.
- store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with
  `@lunora/fingerprint` (the same hash the local Studio computes, so a local
  Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts`
  member-authorized read/triage functions. A `TelemetryStore` adapter
  (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a
  guarded Pipeline→R2 archive, each a no-op without its binding.
- dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the
  `logStreams` entitlement, wired into `OrganizationDashboard`.
- bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket.

Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on
the cloud branch; the graft folds away once Phase 1 lands on alpha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* feat(cloud): observability alerts — rules + delivery (Phase 4) (#141)

* feat(cloud): add observability alerts — rules, firing + delivery

Phase 4 of the observability plan (the "watches while you sleep" tier),
stacked on the Phase 3 ingest.

- schema: `alertRules` (name, target issue/incident, threshold, channel
  email/webhook, destination, enabled) + `alerts` (fired-alert audit trail
  with firing→delivered state, notification denormalized).
- firing: the telemetry `ingest` mutation loads the org's enabled rules and
  fires each the first time a source's count crosses its threshold
  (`before < threshold <= after`, so exactly once), inserting a `firing`
  alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts`
  (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`.
- delivery: the `/v1/telemetry` edge handler delivers fired alerts
  best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps
  them delivered — never blocking or failing ingest.
- functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list,
  markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered).
- dashboard: `AlertsSection` (manage rules + recent fired alerts), gated
  behind the `logStreams` entitlement, wired into `OrganizationDashboard`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* fix(cloud): validate webhook alert destinations against SSRF

An alert rule's webhook `destination` is `fetch`ed by the control plane
when the alert fires, so an owner/admin could otherwise aim it at internal
infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) —
server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https
only, public host, no embedded credentials, no loopback/private/link-local
IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in
`deliverAlert` (never fetch an unsafe target — defense in depth for any
rule created before this guard). String-level, so it can't defeat DNS
rebinding, but it blocks the direct-address cases. Unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cloud): harden webhook SSRF guard

Two SSRF gaps in the Observability alert delivery path:

- deliverAlert followed webhook redirects, so a destination that passes
  isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the
  metadata IP). Set redirect: "manual" and reject 3xx responses.
- isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which
  the URL parser compresses to ::ffff:7f00:1) and the unspecified
  address (::) through. Reject the whole ::-prefixed non-global class.

Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already
blocked via WHATWG URL normalization; added as regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cloud): AI incident triage (@lunora/ai) — Phase 4C (#142)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(runtime): use LunoraError not undefined CirrusError in cloud endpoints

The scheduled-tick and queue-dispatch admin endpoints threw `new
CirrusError(...)`, a class that exists nowhere in the repo, so the file
failed to type-check (TS2304). The intended class is `LunoraError`, already
imported and used throughout the file.

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

* feat(cloud): full tenant log management with structured fields + trace correlation

Consume the structured, trace-correlated logs the framework now emits
(shared/log-event.ts) — the cloud log path kept only 3 severities + a flat
line and had no producer. Closes the framework side of Maple gap #2.

- Producer (GAPS.md B2, the missing piece): src/tail/worker.ts — the
  dispatch-namespace tail worker decodes each tenant `{source:"lunora",
  type:"log"}` console event (src/tail/parse.ts, pure + unit-tested), groups
  them per script, and POSTs batches to POST /v1/logs/tail. Holds one platform
  secret (LUNORA_TAIL_SECRET), not per-org deploy keys; the route resolves
  scriptName → org (logs.orgForScript) and stores via logs.ingestInternal.
  Deployed from tail.wrangler.jsonc.
- Store: tenantLogs widened to the full LogEvent shape — 7-tier severity,
  message, structured fields, functionPath, traceId/spanId, userId, shardKey —
  plus (scriptName, createdAt) and (org, traceId) indexes.
- Query: logs.list gained server-side levels/functionPath/traceId/search
  filters + a cursor and bounded limit, newest-first.
- UI: the studio Logs tab renders severity chips (filter), search, structured
  fields, and a short trace id per line.

Still 🌐: the provisioner setting tail_consumers on tenant scripts, an e2e run,
and correlating error/fatal lines to OTLP Issues by traceId (follow-up).

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

* fix(cloud): provision tenant bindings so deployed workers boot

The deploy handler built the provisioner spec with an empty binding set
(`bindings: {}`), so every uploaded tenant Worker was created with no
Durable Object binding and no `new_sqlite_classes` migration tag. A real
Lunora app always exports ShardDO, so it could never boot — the deploy
pipeline could only ship a binding-less worker.

The deploy request now carries the app's binding manifest (DO classes,
optional per-tenant D1/R2) which the CLI reads from `wrangler.jsonc`, and
the handler normalizes it to a spec that always includes the ShardDO
floor even when a caller under-declares or omits it. Malformed entries
are dropped and the DO list is capped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): tear down Cloudflare scripts for destroyed deployments

The lifecycle crons (cleanupExpiredPreviews, pruneSuperseded,
organizations.purgeDeleted) only transitioned a deployment to `destroyed`
— nothing ever deleted the Cloudflare dispatch script, so dispatch
namespaces grew unboundedly (the leak GAPS.md Ring-2 flagged as closed).

Add a `teardownAt` marker and a pure, port-injected `runTeardownSweep`
(per-target failure isolation, crash-safe idempotent off the marker),
wired into the control-plane Worker's scheduled() handler on the
hourly/6-hourly buckets — right after the crons that mark rows destroyed.
No-ops without Cloudflare credentials.

Per-tenant D1/R2 teardown-by-id still needs resource-id persistence and
is left as a follow-up; script deletion is the load-bearing fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): fold Analytics-Engine usage into the metering ledger

The dispatcher wrote one AE data point per tenant request, but nothing
ever read them back — createHttpAnalyticsReader had no caller, so
`platformUsage` only held what tenants self-report over POST /v1/usage
(nothing, in practice). Spend caps, the usage summary, and the usage
chart therefore evaluated an empty ledger.

Add a per-cell `usageReadAtMs` checkpoint and a pure, port-injected
`runUsageRollback` that delta-reads AE (`timestamp > checkpoint`),
attributes each dispatch script to its org/deployment, and appends
`requests` rows — then advances the checkpoint so re-runs never double
count. A per-row ledger failure is dropped rather than retried (under-
count, never double-bill — the same fail-safe as usage.rollup). Wired
into scheduled() on the hourly/6-hourly buckets; no-ops without
Cloudflare credentials.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): build-queue dispatcher (claim → run → drain)

`builds.claimNext` had no caller: enqueued builds sat untouched until the
24h expiry cron failed them with "no build runner picked this up". Add
the missing claim→run loop as a pure, port-injected `runBuildDispatch`
(bounded per-tick drain; a failed build never aborts the drain), fully
unit-tested against the runner ports.

Production activation stays gated on the runner's 🌐 seams — `execute`
(a throwaway Cloudflare Container running `lunora build`) and
`fetchSource` (GitHub App tarball) — which need live container infra, so
the dispatcher is not yet wired into scheduled(): claiming builds with no
executor would only burn them. This lands the verified logic so the
remaining work is purely the container seam, not the orchestration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* docs(cloud): split GAPS legend into wired vs pure-module (🧩)

The single ✅ conflated "tested pure function exists" with "feature runs".
Add a 🧩 marker for tested-but-uncalled modules, a dated wiring-pass
section covering the four gaps just addressed, and correct the two most
misleading inline entries:

- A3 builds: the claim dispatcher now exists (was missing); only the
  container execute() seam remains 🌐.
- C3 overage credits: reconcileAllOverages / applyCreditPurchase have no
  production caller (verified) — scheduling + webhook mapping are code
  (🔨), not credentials (🌐), so the honest status is 🧩, not "✅ core".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): boot-time route classification scanner

Port of Openship's route-scanner idea (Apache-2.0) to the /v1 router. The
control-plane routes each did inline auth then delegated to a self-
authorizing function, but nothing forced a *new* route to be classified —
an unclassified endpoint could ship silently and read as protected.

Every route now carries an explicit RouteSpec.auth (deployKey / session /
webhookHmac / tailSecret / adminToken / public), and
assertRoutesClassified runs when createDeployRouter builds the table: a
missing/unknown classification, a public route with no reason, or a
duplicate (method, path) throws at construction — the Worker fails to
start rather than serving an unclassified route. The flat dispatch tables
are derived from the one checked list (GET + POST unified). The spec's
opt-in `mcp` field is the allowlist the MCP surface will read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): MCP surface generated from the route registry

Port of Openship's "MCP tools derived from the route registry" idea
(Apache-2.0). A `/v1/mcp` JSON-RPC endpoint (tools/list + tools/call)
exposes only routes that opt in via RouteSpec.mcp, and every tool call
dispatches back through the real router carrying the agent's own bearer
credential — so it runs the identical auth + rate-limit + handler +
function-authz path as any HTTP caller; the MCP layer grants no privilege.

A hard deny-list (buildMcpTools) guarantees token/secret/tenant-access
routes (/v1/secrets, /v1/admin, /v1/invitations/send, /v1/logs/tail) and
the surface itself (/v1/mcp) can never become tools even if mis-annotated
— the same scope-escape guard Openship applies to tokens/auth/mcp. Only
bearer-callable (deployKey/adminToken) opted-in routes are eligible;
session/webhook routes are excluded. deployments.rollback is the first
tool exposed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* chore(codegen): regenerate _generated for teardownAt + usageReadAtMs

Keeps the emitted dataModel/shard/drizzle types consistent with the new
deployments.teardownAt and cells.usageReadAtMs schema columns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): tear down tenant D1 + R2, and test the sweep glue

Extends resource teardown past the dispatch script (#2). The lifecycle
sweep now also deletes the per-tenant D1 database and R2 bucket, resolved
by the same naming convention the provisioner creates them under (shared
tenantD1Name / tenantR2Bucket helpers — no drift, no new persistence).
New CF API methods: findD1DatabaseByName + deleteD1Database (uuid) and
deleteR2Bucket (name). Script + D1 delete are retryable; R2 is best-effort
(a non-empty bucket needs an S3-API object purge the teardown context
lacks — logged, left for follow-up). D1 (every .global() app has one) and
empty R2 buckets are now fully reclaimed.

Also extracts the scheduled() sweep glue into testable port-builders
(#4): teardownPorts + usageRollbackPorts over a structural ControlPlaneDb,
so the row→target mapping, the teardownAt stamp, the ledger insert, and
the per-cell checkpoint are unit-tested against a fake store instead of
living untested inside server.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): carry cronSpecs + bindings from wrangler on deploy

The cron fan-out read live deployments' cronSpecs, but nothing ever
populated them: deployments.create accepted the field yet the deploy
handler/router never passed it, so readCronTargets always returned [] and
the entire §2.4 tenant-cron fan-out had no data source (#3).

Add parseWranglerManifest — a pure reader that extracts the binding
manifest (DO classes / D1 / R2) and cron expressions from a tenant's
wrangler.jsonc — and thread cronSpecs through the deploy request →
handler → create mutation. The deploy client + CLI now forward both
bindings and cronSpecs, so a real deploy provisions what the Worker needs
and registers the crons the fan-out drives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): synthetic uptime monitoring with alerting

Adds external-vantage uptime to the control plane's Observability tier — the
piece a deployment can't self-report (if it's down, it can't say so).

- Probe: pure probeDeployment (generalizes the deploy-time healthCheck — GET,
  sub-500 = up, latency + timeout, never throws), a consecutive-failure state
  machine, and a summarizer, all unit-tested (src/uptime/probe.ts).
- Sweep: runUptimeSweep over injected ControlPlaneDb ports (mirroring the
  teardown/usage sweeps) probes every live deployment, records a uptimeChecks
  row, advances uptimeState, and fires an "uptime" alert the first time a
  deployment's failures cross a rule threshold — reusing crossesThreshold,
  renderAlert, and the alerts table/delivery pipeline (src/uptime/sweep.ts).
- Edge: server.ts scheduled() runs the sweep on the every-minute tick and
  delivers fired alerts over their channel (webhook/email), stamping the outcome.
- Alerts gain an "uptime" target (schema + createRule + renderAlert), so users
  configure "page me when my deployment is down" alongside issue/incident rules.
- Read side: lunora/uptime.ts (summary + recent queries, retention prune cron)
  backs a new Uptime dashboard section.

Cron triggers stay at 3 expressions (prune rides the 6h bucket, the probe rides
the existing every-minute tick). Full suite: 272 tests, lint:types clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019M6G6CAoLVrQMxDYg7BWq2

* fix(cloud): address thermo review of uptime monitoring

Security/correctness (branch audit):
- SSRF: the sweep fetched a …
prisis added a commit that referenced this pull request Aug 7, 2026
…e 3) (#140)

* feat(cloud): add the observability ingest pipeline (issues + incidents)

Phase 3 of the observability plan — durable, cross-deployment monitoring
in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport.

- ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant
  `otlpSink` and the container exporter, decodes the error spans
  (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents
  through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous
  — the cloud app has no queue producer binding, so ingest inserts to D1
  directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the
  plaintext admin token.
- store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with
  `@lunora/fingerprint` (the same hash the local Studio computes, so a local
  Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts`
  member-authorized read/triage functions. A `TelemetryStore` adapter
  (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a
  guarded Pipeline→R2 archive, each a no-op without its binding.
- dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the
  `logStreams` entitlement, wired into `OrganizationDashboard`.
- bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket.

Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on
the cloud branch; the graft folds away once Phase 1 lands on alpha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* feat(cloud): observability alerts — rules + delivery (Phase 4) (#141)

* feat(cloud): add observability alerts — rules, firing + delivery

Phase 4 of the observability plan (the "watches while you sleep" tier),
stacked on the Phase 3 ingest.

- schema: `alertRules` (name, target issue/incident, threshold, channel
  email/webhook, destination, enabled) + `alerts` (fired-alert audit trail
  with firing→delivered state, notification denormalized).
- firing: the telemetry `ingest` mutation loads the org's enabled rules and
  fires each the first time a source's count crosses its threshold
  (`before < threshold <= after`, so exactly once), inserting a `firing`
  alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts`
  (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`.
- delivery: the `/v1/telemetry` edge handler delivers fired alerts
  best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps
  them delivered — never blocking or failing ingest.
- functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list,
  markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered).
- dashboard: `AlertsSection` (manage rules + recent fired alerts), gated
  behind the `logStreams` entitlement, wired into `OrganizationDashboard`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* fix(cloud): validate webhook alert destinations against SSRF

An alert rule's webhook `destination` is `fetch`ed by the control plane
when the alert fires, so an owner/admin could otherwise aim it at internal
infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) —
server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https
only, public host, no embedded credentials, no loopback/private/link-local
IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in
`deliverAlert` (never fetch an unsafe target — defense in depth for any
rule created before this guard). String-level, so it can't defeat DNS
rebinding, but it blocks the direct-address cases. Unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cloud): harden webhook SSRF guard

Two SSRF gaps in the Observability alert delivery path:

- deliverAlert followed webhook redirects, so a destination that passes
  isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the
  metadata IP). Set redirect: "manual" and reject 3xx responses.
- isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which
  the URL parser compresses to ::ffff:7f00:1) and the unspecified
  address (::) through. Reject the whole ::-prefixed non-global class.

Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already
blocked via WHATWG URL normalization; added as regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
prisis added a commit that referenced this pull request Aug 7, 2026
…rts, sessions, dashboards (#179)

* feat(studio): browse the durable log archive in the Logs panel (#155)

* feat(studio): browse the durable log archive in the Logs panel

Add a third "Archive" feed to the studio Logs panel that reads the durable
ctx.log archive pipelineLogSink writes to R2 (Iceberg / R2 Data Catalog).

- @lunora/runtime: a new admin-gated `/_lunora/admin/logs/archive` route
  (`log-archive-admin-routes.ts`) runs `createPipelineLogReader` server-side —
  the R2 SQL token stays on the worker, only decoded `{ rows, nextCursor }`
  reaches the browser. Reads creds from env (`R2_SQL_*`, `CLOUDFLARE_ACCOUNT_ID`
  fallback) + the table from a new `logArchive` WorkerOption. Fails closed with
  `LOG_ARCHIVE_NOT_CONFIGURED` when unwired.
- @lunora/client: `queryLogArchive(query)` method + re-exported PipelineLog*
  wire types (owned by @lunora/runtime).
- @lunora/studio: a self-contained `ArchiveFeed` (function/user/min-level
  filters, keyset "Load more", a "not configured" empty state distinct from an
  error) rendered under the new Archive tab; `errorCode` helper.
- Docs + API snapshots updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* refactor(studio): drop try/finally in ArchiveFeed for React Compiler

The React Compiler bails on a `try` with a `finally` clause (React Doctor
`react-hooks-js/todo`), so the component missed automatic memoization. Reset
`loading` in each branch instead, matching the repo's no-`finally` pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* refactor(studio): address thermos review of the archive feed

Code-quality + correctness follow-ups from the two-pass review:

- Extract the duplicated LEVEL_VARIANT map into `log-level-variant.ts`, shared by
  the live Logs feeds and the Archive feed (restores the exhaustive
  `Record<LogLevel, BadgeVariant>` type — drops the `?? "outline"` fallback).
- Collapse the four `view !== "archive"` readout guards in logs-panel into one
  `view === "archive" ? <ArchiveFeed/> : <>…</>` branch.
- Drop the `JSON.parse(JSON.stringify(baseQuery))` round-trip in the fetch effect
  — pass `baseQuery` directly, keying the effect on `querySignature`.
- Guard `loadMore` against a cross-filter race: a page-2 fetch that resolves
  after a filter change is dropped (via `activeSignatureRef`) instead of
  appending stale rows / overwriting the cursor.
- Show a "Loading…" placeholder on the initial fetch instead of a blank panel.
- Type `minLevel` state as `"" | ContextLogLevel` (removes a cast).
- Move the `LOG_ARCHIVE_NOT_CONFIGURED` sentinel to `shared/log-archive.ts` so
  runtime and studio share one source of truth with no dependency edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* refactor(studio): address CodeRabbit review of the archive feed

- Fold ArchiveFeed's five fetch-related useState into one useReducer, so each
  fetch transition (loading / loaded / append / notConfigured / failed /
  pageFailed) is a single dispatched action (React Doctor prefer-useReducer).
- Use the imported `ChangeEvent` type instead of the `React.*` namespace, matching
  logs-panel.tsx.
- Gate the toolbar `LiveError` on `view !== "archive"` so the disabled Errors
  feed's live-connection state can't leak into the (WS-less) Archive tab.

The CodeRabbit "cast env to LogArchiveEnvironment" suggestion is intentionally
skipped: `env ?? {}` narrows to `{}`, which is assignable to the all-optional
LogArchiveEnvironment (lint:types is green), and eslint's
no-unnecessary-type-assertion rejects the cast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* polish(studio): clear prior error on a fresh archive fetch

Thermos re-review nice-to-have: the reducer's `loading` action now clears
`error`/`notConfigured` (matching the kv reducers' `submitStart`), so retrying
after a failure shows the loading placeholder instead of the stale error line.
Rows are kept, so paging / filter-change refetches don't blank the table.

(Kept `default: return state` to stay consistent with the existing kv reducers
rather than introduce a one-off `unreachable` exhaustiveness guard.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(release): @lunora/runtime@1.0.0-alpha.32 [skip ci]\n\n## @lunora/runtime [1.0.0-alpha.32](https://github.com/anolilab/lunora/compare/%40lunora%2Fruntime%401.0.0-alpha.31...%40lunora%2Fruntime%401.0.0-alpha.32) (2026-07-21)

* chore(release): @lunora/client@1.0.0-alpha.26 [skip ci]\n\n## @lunora/client [1.0.0-alpha.26](https://github.com/anolilab/lunora/compare/%40lunora%2Fclient%401.0.0-alpha.25...%40lunora%2Fclient%401.0.0-alpha.26) (2026-07-21)

* **@lunora/runtime:** upgraded to 1.0.0-alpha.32

* docs: add cirrus cloud platform plan

Reverses the managed-deploy-plane won't-do (VOID-TEARDOWN.md §0/§6,
CONVEX-PARITY.md #23) with a scoped managed tier: Workers for Platforms
data plane, Convex-shaped product model (teams/projects/prod+dev+preview
deployments), PartyKit-style managed-vs-BYO CLI split, and a phased
roadmap starting with remote-binding dev. Synthesized from a repo
inventory, a Convex Cloud teardown, and a GitHub/Cloudflare-primitives
survey.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: fold supabase platform teardown into cloud plan

Adds Supabase as a reference model: the OSS/proprietary cut line,
IS_PLATFORM single-codebase studio pattern, Branching 2.0 preview DX
(and its pain points to fix: empty branches, hourly branch billing
outside spend caps), the Management API + OAuth-apps growth channel,
and the structural cost advantage WfP gives over per-project VMs.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add wfp constraints, eject path, spike checklist

Gap review of the cloud plan: documents that cron triggers are
silently dropped for namespaced user Workers (with SchedulerDO
alarm-based fan-out mitigation), queue-consumer and send_email
verification items, KV account-limit multiplexing, EU jurisdiction
toggle, a cirrus-eject portability command built on existing
export/import RPCs, namespace-wide observability reuse, managed
backups + abuse controls in Phase 4, and a Phase 1 constraint spike.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: verify cloud plan claims against cloudflare docs

Fact-checked every Cloudflare claim in CLOUD-PLAN.md against the
official docs: WfP pricing, KV/D1/R2 account limits, DO/R2
jurisdictions vs D1 location hints, CF for SaaS hostname pricing, and
remote-bindings GA versions all confirmed. Adds three newly verified
constraints: no gradual deployments for user Workers (rollback must be
platform-side bundle re-upload), the 1200-req/5-min account API rate
limit on provisioning, and outbound-Worker TCP/DO interception
trade-offs.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add cell-based scaling architecture to cloud plan

Answers how the managed tier scales without hitting account limits or
risking platform-wide blocks: script-resident tenant state with lazy
inference-driven provisioning, multi-account cells with cell IDs baked
into identifiers from day one, a per-cell API token-bucket scheduler,
a tenancy graduation ladder up to managed-BYO and the Tenant API, and
abuse containment to keep tenant abuse from looking like platform
abuse.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: scope alchemy to cell bring-up, not tenant deploys

Records the provisioning-engine decision: the per-tenant deploy path
stays hand-rolled on cloudflare-typescript (control-plane DB as the
single source of truth, cell scheduler, progress events, rollback
artifacts); Alchemy (pre-1.0, v2 rewrite underway, no confirmed
dispatch-namespace resource) is a candidate only for low-cardinality
cell bring-up IaC, with Terraform/Pulumi as the fallback.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: correct alchemy facts (dispatch-namespace resource, v0.93)

Re-checked Alchemy against GitHub/npm: it is v0.93.12 (Apache-2.0) and
does ship a dispatch-namespace (Workers for Platforms) resource — my
earlier 'lacks a confirmed dispatch-namespace resource' was wrong and
'v0.9x' undersold it. Recommendation is unchanged (hand-roll the
per-tenant deploy, use Alchemy for cell bring-up) but now rests on the
real reason — source-of-truth shape and deploy-orchestration concerns,
not capability — with a re-evaluation trigger at a stable 1.x.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: adopt alchemy as the provisioning engine

Decision: Alchemy is the provisioning engine across cell bring-up,
per-tenant managed deploy, and BYO. Verified it ships DispatchNamespace/
Worker/D1/R2/DO resources plus a built-in D1StateStore and runs inside a
Worker (await alchemy(scope) -> finalize/destroy). Backing each tenant
scope with the control-plane D1 collapses the two-sources-of-truth
concern into one store. The per-cell rate-limit scheduler now paces
finalize() runs; bundling stays in the Vite pipeline; rollback re-
converges to a prior R2-retained bundle. Risk of a 0.x dependency
contained behind a @cirrus/provision adapter with cloudflare-typescript
as fallback; spike open items recorded.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: build on alchemy v2 (alchemy@next, 2.0.0-beta.55)

Per decision to start on the v2 line: target alchemy@next (verified
2.0.0-beta.55, Effect-based) to avoid a v1->v2 migration mid-build,
with v1 0.93.x as the named fallback. Records the trade-offs (beta
churn, Effect pulled into the control-plane tree, quarantined behind
the @cirrus/provision adapter) and turns the unverified v2 facts
(DispatchNamespace resource + D1/DO state store, confirmed on v1 only
since docs/CDN were unreachable) into hard Phase 1 spike gates with
v1 fallback per surface.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: commit fully to alchemy v2, drop v1 fallback

Remove the v1 (0.93.x) fallback hedging throughout: v2 (alchemy@next,
2.0.0-beta.55) is the engine outright. Spike gates remain but now
resolve via owned shims or upstream contributions rather than retreat
to v1; a hard unresolvable 'no' escalates the engine decision instead
of silently dual-tracking.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add forgotten must-haves + fleet runtime-versioning risk

Gap pass on the cloud plan. New risk #8 (the load-bearing one): the
Cirrus runtime is bundled into each tenant Worker, so a security patch
means redeploying the whole fleet unless the tenant Worker is made
'thin' against a central runtime — a fat-vs-thin decision that must be
made before Phase 1 since it shapes the bundle format, deploy API, and
vite emit. New section 7 collects launch-blocking gaps the plan had
assumed away: control-plane DB durability/DR, cross-cell disaster
recovery, secrets-at-rest + cell-token custody, frontend-hosting scope,
AUP + bill-shock/cryptomining controls, billing/MoR/tax, GDPR
processor/DPA/SOC2, account offboarding + right-to-erasure, platform
self-observability + status page, dispatcher canary, and a staging cell.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): scaffold control-plane app built on cirrus

First implementation step from CLOUD-PLAN.md: a new apps/cloud workspace
app that dogfoods Cirrus as the platform's own control-plane backend.

- cirrus/schema.ts: control-plane data model (cells, organizations,
  members, projects, deployments, deployKeys, auditLog), all .global()
  (D1) — the plan's 'Worker + D1' control plane.
- cirrus functions: organizations/projects/deployments/cells/deploy-keys
  (create/list/issue/updateStatus), with owner seeding + audit trail.
- src/server.ts: control-plane Worker entry wiring D1-backed global tables.
- src/provision.ts: the @cirrus/provision seam — the sole coupling to the
  Alchemy v2 engine (stub that rejects until the Phase 1 spike wires it).
- configs (package.json/tsconfig/project.json/wrangler.jsonc/eslint/vitest),
  README, and a provision test.

Verified: codegen clean (no advisories), eslint clean, tsc --noEmit
passes, vitest green.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add deploy-orchestration core

Builds the next control-plane layer on the scaffold's Provisioner seam,
all pure/testable (no live Cloudflare needed):

- token-bucket.ts: per-cell API budget (§2.5), models CF's 1200/5min
  account limit; deterministic + clock-injectable.
- scheduler.ts: CellScheduler paces/serializes provisioner work against
  the bucket with priority ordering + a concurrency cap.
- orchestrator.ts: runDeployment state machine emitting queued →
  provisioning → live/failed progress events (§2.2); destroyDeployment
  for preview-TTL/project teardown.
- keys.ts: deploy-key format/parse/hash helpers; deploy-keys.ts mutation
  refactored to use them (one tested place for the format + SHA-256).

17 tests across 5 files; eslint + tsc --noEmit clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add org authorization + deploy-key lifecycle

Closes gaps found reviewing the control plane:

- authz.ts: assertMember(ctx, orgId, roles?) — the org ACL gate. Every
  org-scoped function now verifies the caller is a member with a
  permitted role, closing an IDOR hole where any signed-in user could
  read/mutate any org by passing its id. Applied across projects,
  deployments, deploy-keys.
- members.ts: list / add / remove so memberships can actually be granted
  (owner is seeded on org create; admins/owners manage the rest).
- deploy-keys: verify (the deploy API's auth path — match by SHA-256,
  reject revoked, bump lastUsedAt, return the DB-authoritative target)
  and revoke (leaked-key mitigation); lastUsedAt/revokedAt are now live.
- deployments: create checks the project belongs to the org; updateStatus
  loads the deployment and gates on its org (documented as the system
  seam for the orchestrator).

codegen clean, eslint + tsc --noEmit clean, 17 tests pass.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): deploy API endpoint, deploy-key auth, handler tests

Lands the three remaining pieces together:

- Deploy API: POST /v1/deploy mounted via the httpRouter seam (src/deploy/
  router.ts) → pure handler (src/deploy/handler.ts) authenticates the bearer
  deploy key, records a queued deployment, drives runDeployment through the
  per-cell scheduler, and streams NDJSON progress (accepted→queued→
  provisioning→live/failed→done), patching status per phase.
- Auth path: investigation showed internalMutation is unreachable from the
  HTTP action-context dispatch (no system flag → RPC 404), so verify/
  updateStatus stay public; instead added deploy-key authorization
  (authz.authorizeDeployKey) and a dual-path (member session OR deploy key)
  on deployments.create/updateStatus, so CI deploys need no user session.
  Corrected the stale 'should become internalMutation' comments.
- Tests: handleDeployRequest (401/403/400 + success and failure streaming +
  status transitions) and authz (assertMember + authorizeDeployKey) via a
  fake ctx. 28 tests / 7 files; eslint + tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): refresh status — deploy API + auth now in place

* chore(cloud): track generated schema snapshot

Matches the apps/playground convention — .cirrus-schema.json is the
codegen schema snapshot used for migration/drift detection and is
committed, not gitignored.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): preview lifecycle, GitHub webhook, deploy client (Phase 1/2)

Phase 2:
- Preview deployments are TTL'd: deployments.create stamps expiresAt for
  kind=preview (src/deploy/preview.ts: deterministic previewScriptName +
  5-day previewExpiry); an hourly cron (cirrus/crons.ts -> internal
  deployments.cleanupExpiredPreviews) marks expired previews destroyed.
  Worker gains scheduled(); wrangler cron trigger added.
- GitHub webhook (src/github/webhook.ts): HMAC-SHA256 verify + pull_request
  -> preview-intent parsing, mounted at POST /v1/github/webhook.

Phase 1:
- Deploy client (src/deploy/client.ts): the cirrus-deploy core — POSTs to
  /v1/deploy and consumes the NDJSON progress stream.

13 new tests (41 total); codegen/eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: update roadmap — Phase 0 shipped, Phases 1-2 status

Phase 0 (remote-binding dev) is already implemented in the framework
(@cirrus/config remote-bindings + @cirrus/vite plugin + cirrus dev;
30 tests) — corrected from 'not started'. Phases 1-2 marked
substantially-built with the live-Cloudflare-dependent remainder
(Alchemy provisioner, dispatcher, e2e validation) called out.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): team invitations + billing quotas; repo-extraction guide (Phase 3/4)

Phase 3 (hosted studio sliver):
- Team invitations (cirrus/invitations.ts + invitations table): invite/list/
  revoke/accept, single-use SHA-256-hashed tokens (plaintext mailed once),
  owner-admin gated; accept-by-token adds the caller as a member.

Phase 4 (billing sliver):
- Plans + quota entitlements (src/billing/plans.ts) on @cirrus/payment's
  entitlements model — free/pro/enterprise limits + feature flags, with
  effectiveLimit/withinQuota and a free-tier fallback for non-subscribers.

Portability (move to a private repo):
- EXTRACT.md documents the mechanical extraction; audit confirms the app
  imports only published @cirrus/* entry points (no monorepo-internal reaches).

6 new tests (47 total); codegen/eslint/tsc clean; secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): real Cloudflare provisioner, quota enforcement, webhook project resolution

Provisioner (Phase 1 — the big one): replaces the rejecting stub with a real
implementation over a typed Cloudflare REST port (src/cloudflare/api.ts):
deploy provisions per-tenant D1/R2, uploads the user Worker into the dispatch
namespace with binding + DO-migration metadata, applies secrets, returns the
bundle hash + routed URL; destroy deletes the script. Port-injected so it's
tested with a fake; plug in CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN to run.
(REST via fetch rather than the unverifiable alchemy@next beta — same seam.)

Quota (Phase 4): plans.ts gains planLimit/withinPlanQuota; projects.create and
members.add enforce the org plan's limits.

Preview automation (Phase 2): projects gain githubRepo + byGithubRepo lookup;
the webhook resolves the connected project and returns the preview script name.

Env documented (.dev.vars.example + wrangler vars). 50 tests; eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): dispatcher Worker + hosted-studio admin-RPC proxy (Phase 1/3)

Phase 1 — dispatcher Worker (the request-path front door): resolveTenant
maps {scriptName}.{appDomain} (and custom domains via injected lookup) to a
dispatch-namespace script; the worker forwards via env.DISPATCHER.get with
per-plan limits. Separate deployable (dispatcher.wrangler.jsonc).

Phase 3 — admin-RPC proxy: proxyAdminRequest authorizes org membership,
forwards the admin RPC to the tenant's /_cirrus/admin/* with that
deployment's admin token, and records an audit entry. Pure (deps injected).

7 new tests (57 total); eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): cirrus login/link/deploy CLI commands (Phase 1)

Pure command logic over a ConfigStore + the deploy client: login persists the
API endpoint + deploy key, link binds a project, deploy streams a managed
deploy (requires login+link). File-backed store at ~/.cirrus/cloud.json for
the Node CLI; cerebro registration in @cirrus/cli calls these.

3 new tests (60 total); eslint/tsc clean; secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: roadmap status — control-plane backend feature-complete as code

All phases' backend code is built + unit-tested in apps/cloud (60 tests):
real REST provisioner, dispatcher, CLI, preview lifecycle, GitHub webhook,
team invitations, admin-RPC proxy, quota enforcement. Remaining items are
the ones needing live Cloudflare / external services / the studio UI.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): admin-proxy live wiring, usage metering, custom-hostname port (Phase 3/4)

Phase 3 — admin proxy mounted at POST /v1/admin: deployments now carry the
platform-minted tenant adminToken (set as the worker's CIRRUS_ADMIN_TOKEN
secret + stored on the row), deployments.adminTarget resolves {url, adminToken}
after asserting membership, and the router forwards to the tenant's
/_cirrus/admin/* with an audit-log.record entry.

Phase 4 — usage metering: usageEvents table + internal record mutation +
member summary query over a pure aggregateUsage roll-up. Custom hostnames:
CloudflareApi.createCustomHostname (Cloudflare for SaaS, zone-scoped REST).

Router refactored into per-route handlers. 64 tests; eslint/tsc/secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: roadmap — admin proxy mounted, usage metering + custom-hostname port added

* feat(cloud): add hosted studio react spa

Build the hosted-studio frontend for the Cirrus Cloud control plane: a
better-auth-gated React SPA served on one origin with the control-plane
Worker via @cirrus/vite.

- src/client: main/auth-client/Login, App auth gate, OrganizationList,
  OrganizationDashboard with tabs for projects, deployments, members,
  deploy keys, invitations, and usage; AsyncList loading/empty helper.
- Wire @cirrus/auth into src/server.ts (createAuth + cirrusD1Adapter,
  ensureMigrated, handleAuthRequest, authAdmin, resolveIdentity) and add
  AUTH_SECRET/AUTH_URL env + .dev.vars.example entries.
- Switch package scripts to vite (build/dev), add react/react-dom +
  @cirrus/react/@cirrus/auth deps, vite.config.ts, index.html, and the
  DOM lib + jsx in tsconfig.
- eslint: client section (filename-case, react-perf, void), browser
  globals; ignore vite.config.ts.
- Refresh README + CLOUD-PLAN status to reflect the studio UI.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add billing, metering, and hardened auth

Billing on @cirrus/payment (§4): org id is the payment referenceId. Wire a
Stripe adapter into createShardDO({ payment }); add cirrus/billing.ts with
checkout/portal actions, entitlements/subscription reads (resolved through
CIRRUS_CLOUD_PLANS with a free-tier fallback), and a signature-verified
processWebhook mounted at POST /v1/billing/webhook. The studio gains a
Billing tab.

Platform metering (§4): rename the resource-metering table to platformUsage
(freeing usageEvents for @cirrus/payment's billing ledger), add a deploy-key
authenticated usage.ingest mutation + POST /v1/usage endpoint, and enforce
per-plan runtime limits in the dispatcher (limitsForPlan → DISPATCHER.get).

Auth hardening (§3) on @cirrus/auth/better-auth: mail-backed email
verification + password reset (@cirrus/mail), optional GitHub/Google OAuth,
admin/twoFactor/passkey plugins, built-in auth rate limiting, plus a per-IP
@cirrus/ratelimit cap on the /v1/* surface. Invitations now email the token
via POST /v1/invitations/send (never shown in the browser). The Cirrus
organizations/members model stays the single org source of truth (better-auth
organization plugin deliberately omitted).

Add deps (@cirrus/mail, @cirrus/ratelimit, stripe), tests for the router
routes + rate limiting + per-plan limits (69 total), and reconcile the README
+ CLOUD-PLAN status (the provisioner is a real Cloudflare REST impl, not a
stub).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): enforce entitlements, wire metering source, add secrets

Close the billing loose ends and add the metering source, tenant secrets,
and an audit-log view.

Entitlements (close loose end #1): quota is now enforced against live
subscription state (cirrus/entitlements.ts resolves from the synced
`subscriptions` table) rather than the static organizations.plan column —
projects/members creation call assertWithinQuota, so a Stripe upgrade raises
limits immediately with no column to sync.

Per-plan dispatch limits (close loose end #2): deployments.planForScript +
a bearer-gated GET /v1/tenants/plan endpoint + a cached plan resolver in the
dispatcher (createPlanResolver) wire resolvePlan, so runtime limits actually
scale per plan instead of always falling back to free.

Metering source: the dispatcher emits one Analytics Engine data point per
tenant request (src/metering/analytics.ts); a reader port + HTTP impl and an
hourly usage.rollup compaction cron complete the pipeline alongside the
existing /v1/usage ledger ingest.

Tenant secrets (§7): AES-256-GCM envelope encryption at the edge
(src/secrets/crypto.ts), a secrets table (ciphertext + IV only), store/list/
listEncrypted/remove functions, POST /v1/secrets, deploy-time materialization
into the tenant Worker, and a studio Secrets tab.

Studio: add Secrets + Activity (audit log) tabs; add audit-log.list.

Tests: crypto round-trip, plan resolver caching/fallback, entitlement quota,
analytics writer/reader (83 total). Docs + .dev.vars.example updated.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): address review findings (rollup atomicity, deploy failure, dedup)

Apply /review findings on the recent billing/metering/secrets work:

Correctness:
- usage.rollup: the D1 global backend has no multi-statement transaction, so
  the old insert-summed-then-delete-originals order could double-count (over-
  bill) on a mid-rollup crash. Reorder to delete the extras first, then patch
  the surviving row's total last — a crash can now only under-count, never
  leave a summed row beside surviving originals.
- deploy handler: a tenant-secret decrypt failure (corrupt secret / rotated
  key) threw inside the NDJSON stream and left the deployment stuck in
  `accepted`. Catch it and transition to `failed` with a status update.
- POST /v1/secrets: encryption/config failures (e.g. a malformed
  SECRET_ENCRYPTION_KEY) now return 500, not a misleading 403 (kept distinct
  from the membership 403 the store mutation raises); reject the reserved
  CIRRUS_ADMIN_TOKEN secret name up front instead of silently clobbering it.
- studio: drop the plan picker from org creation — limits now come from live
  subscription entitlements, so selecting a paid plan at create-time granted
  nothing. Orgs start free; upgrade via the Billing tab.

Cleanup:
- Extract the cross-org IDOR guard into authz.assertRowInOrg and call it from
  secrets/members/deploy-keys/invitations (was four byte-identical copies).
- Remove dead plans.ts exports planLimit/withinPlanQuota (superseded by
  entitlements-based quota); add a single highestPlan/PLAN_PRECEDENCE helper
  and use it in deployments.planForScript (was a hand-rolled tier ladder).
- Memoize the Stripe payment config per isolate (was rebuilt on every shard
  request that touches ctx.payments).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* test(cloud): validate websockets through dispatch (phase 1 spike)

Validate the hottest path — hibernated-WS subscriptions + per-invocation
limits through env.DISPATCHER.get() — the least-documented WfP case (risk #3).

- spikes/ws-dispatch/: a runnable harness for live validation on a real
  dispatch namespace. A framework-free hibernatable-WebSocket Durable Object
  (the exact primitive ShardDO uses: acceptWebSocket + webSocketMessage),
  deployable into the namespace, plus a zero-dep Node probe that drives it
  through the dispatcher and asserts: (1) the WS upgrade survives the dispatch
  hop (101 + live socket), (2) a hibernated server push (broadcast) reaches the
  socket — the mutation-to-subscription shape, (3) cpuMs-limit behaviour. The
  README documents deploy/run, pass/fail, and the expected results + caveats.
- __tests__/dispatcher-ws.test.ts: unit-pins the dispatcher forwarding
  contract (returns the tenant 101+webSocket response unchanged, applies
  per-plan limits, meters the upgrade once) — runs in CI, no infra needed.
- dispatcher worker: clarifying comments on WS pass-through + per-frame
  metering semantics. CLOUD-PLAN risk #3 now references the harness.

The dispatcher half is verified here (94 tests); the end-to-end behaviour
needs a live Cloudflare account + the Workers-for-Platforms add-on to run.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant cron fan-out through dispatch (wfp workaround)

Cloudflare drops triggers.crons for Workers in a dispatch namespace, so tenant
cron jobs never fire. Fan them out from the control plane (CLOUD-PLAN §2.4).

- @cirrus/runtime: add an admin-gated POST /_cirrus/scheduled tick endpoint
  that runs a cron expression's jobs through the SAME handleScheduled path the
  native scheduled() trigger uses (user crons + code crons + backup), so a
  platform can drive a namespaced tenant's crons over HTTP. (Dispatch stubs
  expose only fetch()/connect() — no scheduled()/queue() — so HTTP is the only
  transport in.)
- src/fanout/cron.ts: pure 5-field cron-expression matching (lists, ranges,
  steps, dom/dow OR semantics) + dueTicks + fanOutCron orchestration.
- control plane: capture each tenant's cronSpecs on the deployments row at
  deploy; an every-minute heartbeat cron (cirrus/fanout.ts) makes codegen emit
  the */1 trigger, and server.ts scheduled() reads live cron targets and ticks
  each due tenant via env.DISPATCHER.get(script).fetch('/_cirrus/scheduled')
  with the per-deployment admin token (kept in-process — never exposed). Adds
  the DISPATCHER binding to the control-plane wrangler.

Tests: cron matching, dueTicks, fanOutCron (103 cloud tests; 337 runtime
tests still green). Live validation on a dispatch namespace pending; queue
consumer fan-out is the remaining half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant queue-consumer fan-out through dispatch (wfp workaround)

WfP namespaced Workers can't be queue consumers, so tenant queue-backed work
(@cirrus/mail sends, scheduler queue-workpool) never drains. Fan it out from a
platform-owned consumer (CLOUD-PLAN §2.4) — the queue counterpart to the cron
fan-out.

- @cirrus/runtime: add a `queueHandler` option + an admin-gated
  POST /_cirrus/queue endpoint that reconstructs the batch and invokes it,
  returning the message ids to retry. (Dispatch stubs are fetch-only, so HTTP
  is the only transport into a namespaced tenant.)
- src/fanout/queue.ts: pure grouping of a shared-queue batch by the producing
  tenant's script (envelope `{ script, body }`) + fan-out orchestration that
  collects per-message retries and retries a whole group on delivery failure.
- control plane: the account-level Worker is the consumer — server.ts queue()
  drains the shared cirrus-tenant-queue, resolves each tenant's admin token
  in-process (never exposed), forwards sub-batches via
  env.DISPATCHER.get(script).fetch('/_cirrus/queue'), and acks/retries per the
  tenant reply. Adds the queues.consumers binding to the control-plane wrangler.

Tests: groupByTenant + fanOutQueue (108 cloud tests; 337 runtime tests green).
Live validation on a dispatch namespace + a producer-side script-tagging helper
remain.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): align with the lunora rebrand + reuse @lunora/analytics

Rebased onto alpha, which renamed the framework cirrus → lunora. Reconcile the
control-plane app and reuse a newly-shipped package.

Rebrand:
- npm scope @cirrus/* → @lunora/* across deps + imports.
- app functions dir cirrus/ → lunora/ (+ tsconfig/eslint globs, _generated
  paths, the committed schema snapshot → .lunora-schema.json).
- reserved paths /_cirrus/* → /_lunora/* (incl. the new scheduled/queue tick
  endpoints) and the runtime-injected env.__lunoraCtx; renamed exported symbols
  (LunoraError, LunoraClient/Provider, useLunora, lunoraD1Adapter, LunoraAuth*,
  LUNORA_CRONS/FUNCTIONS); vite plugin cirrus() → lunora(); CLI config dir
  ~/.cirrus → ~/.lunora.
- wire the new required GlobalIntrospector.facetColumn via @lunora/d1's
  facetGlobalColumn.

Reuse:
- src/metering/analytics.ts is now a thin domain layer over @lunora/analytics
  (createAnalytics writeDataPoint + createAnalyticsSqlClient AE-SQL reader)
  instead of a hand-rolled writeDataPoint + HTTP SQL client.

Verified the rest is genuinely cloud-specific (cron-expression matching, AES-GCM
secret crypto, the Cloudflare REST provisioner, the per-cell CF-API token
bucket) — no upstream equivalent to fold into.

103→108 cloud tests green; runtime 379 tests green; tsc/eslint/build clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): rebrand the product Cirrus Cloud → Lunora Cloud

Complete the lunora rebrand to the product layer (the framework already moved):

- brand prose Cirrus Cloud → Lunora Cloud across code comments, README,
  EXTRACT, the studio (index.html title, Login/dashboard), and CLOUD-PLAN.md.
- env vars CIRRUS_* → LUNORA_*: LUNORA_ADMIN_TOKEN and LUNORA_MAIL_CAPTURE are
  functional (read by @lunora/mail); LUNORA_APP_DOMAIN / LUNORA_CELL and the
  VITE_LUNORA_URL client var follow for consistency.
- the LUNORA_CLOUD_PLANS entitlements constant.
- infra names cirrus-* → lunora-*: worker names (lunora-cloud, lunora-dispatcher),
  dispatch namespace (lunora-production), shared queue (lunora-tenant-queue),
  AE dataset (lunora_tenant_usage), the lunora.app apex, and the deploy
  dispatch-namespace prefix.
- the hosted-CLI verbs (lunora login/link/deploy) and config dir ~/.lunora.
- docs' reserved-path/marker refs (/_lunora/*, __lunora_admin__, env.__lunoraCtx).

108 cloud tests green; tsc/eslint/build clean; zero residual `cirrus` references.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* refactor(cloud): migrate functions to fluent builder api

Adapt the cloud control-plane functions to alpha v1.0.0-alpha.1's fluent
function builders: kind.input({...}).<terminal>(({ ctx, args }) => ...)
replaces the removed object form kind({ args, handler }). Regenerate
_generated/* and pick up codegen's observability block in wrangler.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): license under polyform noncommercial

The control plane is the proprietary product layer, so it must not carry
the framework's FSL-1.1-Apache-2.0 (which grants broad commercial rights).
Apply PolyForm Noncommercial 1.0.0: any noncommercial purpose is permitted,
but commercial use requires a separate license. Replaces UNLICENSED.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): adopt @lunora/bindings/analytics after package fold-in

The latest alpha folded @lunora/analytics into @lunora/bindings (subpath
export ./analytics, identical API) and codegen now emits
_generated/functions.ts importing @lunora/values directly. Swap the
dependency and import specifiers, declare @lunora/values, regenerate
_generated/*, and reconcile the lockfile.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): address pr review findings

- secrets: assert the project belongs to the org in store/list/
  listEncrypted and scope queries by organizationId, closing the
  cross-org IDOR where a member of one org could read or overwrite
  another org's project secrets (+ idor tests)
- deploy: require a base64 worker bundle in POST /v1/deploy and thread
  it client → CLI → provisioner instead of uploading an empty module;
  400 on missing/malformed bundle
- studio: replace try/finally + throw-in-try with promise combinators
  in Login/Invitations/Secrets forms so React Compiler can memoize them

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): add consolidated gap analysis and build plan

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): blue/green releases with health gating and rollback

Every deployment now uploads an immutable versioned script
({alias}-v{n}); the project's stable URL follows an active-deployment
pointer that only swaps after the new script passes a health probe, so
a bad deploy never replaces a serving one (gaps.md a1). Adds
POST /v1/deployments/rollback + lunora rollback (pointer swap back to a
retained superseded release), GET /v1/tenants/route + a cached alias
resolver in the dispatcher, per-phase deployment timestamps (a2), and
an x-lunora-id debug header (b3).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): aggregate spend caps with org suspension

Per-invocation limits cap one request; nothing capped aggregate period
spend (gaps.md c1). Adds a pure spend evaluator at the wfp cost basis
with per-plan default caps (org-overridable; explicit 0 = uncapped), an
hourly enforcement cron that suspends breaching orgs and self-heals
recovered ones, and dispatcher enforcement — a suspended org's tenants
serve 503 via the sentinel plan carried through the existing cache.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): custom domains — model, txt verification, hostname routing

First slice of gaps.md b1: the domains table (unique hostname, per-org
project scoping, redirect-only rows, cloudflare custom-hostname id),
add/list/remove/markVerified functions with the same authz gates as
secrets, a pure dns-over-https verification core (_lunora txt token +
platform cname check, injectable resolver), and routeForHostname — the
dispatcher-facing lookup that only ever routes verified domains to the
project's active script. Edge routes + dispatcher wiring land next.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): wire custom domains through edge and dispatcher

Completes the code-tractable half of gaps.md b1: POST /v1/domains (add,
returns the txt record to create), POST /v1/domains/verify (dns-over-
https txt + cname checks under the caller's session, outcome recorded
via markVerified), GET /v1/tenants/custom-domain for the dispatcher,
and a cached custom-domain resolver in the dispatcher that routes
verified hostnames to the project's active script and answers
redirect-only rows directly. cloudflare-for-saas cert provisioning
remains the 🌐 half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* style(cloud): hoist the trailing-dot regex to module scope

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): server-side builds, build logs, and push-to-deploy webhook

gaps.md a3/a4: builds table with a stale-recoverable work lease and
commit-sha dedup (a repeat push reuses the successful build's bundle
hash instead of rebuilding), streamed line-per-row build logs with a
cursor-paginated tail query, github app installations linked by account
slug, push + installation webhook parsing (default-branch pushes only,
zero-sha deletes ignored) wired through the hmac-verified edge route,
and a pure build-runner orchestration (claim → fetch → execute →
complete/fail) whose tarball-fetch and container-execute ports are the
remaining 🌐 half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant log ingestion and org right-to-erasure

gaps.md b2 + d3. logs: a tenantLogs ledger fed by the tail worker via
deploy-key-gated POST /v1/logs/ingest (batch + line-length caps, lines
truncated rather than dropped), a cursor-paginated member tail query,
and a 6-hourly retention prune (48h window). erasure: owners request
org deletion (30-day reversible window); the purge cron then erases
every org-scoped control-plane row, marks deployments destroyed for
the provisioner teardown path, and removes the org.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): mark shipped gaps in the build plan

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): dunning state machine and residency-aware cell placement

gaps.md c2 + f. dunning: a pure evaluator (payment failure → 14-day
grace anchored at first failure → suspend; any active/trialing
subscription rescues) driven by a 6-hourly cron over the synced
subscription states. suspensions now carry a reason so the spend-cap
and dunning crons only lift their own. placement: organizations.create
accepts a jurisdiction ("eu"/"fedramp") and picks a matching active
cell when no explicit cellId is given.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): lunora eject core — the no-lock-in exit hatch

gaps.md d2: a pure eject flow that pulls the tenant's full data
snapshot through its admin export api, scaffolds the byo wrangler.jsonc
the project would have had outside the platform (do bindings, d1
placeholder, sqlite migrations), and writes a restore readme — all over
injected ports so the packaging is fully unit-tested; the cli wires the
real i/o.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* style(cloud): use a template literal in the eject scaffold

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): studio tabs for domains, builds, and runtime logs

wires the round-7 backends into the hosted studio: a domains tab (add →
txt record callout → verify → live verified badge, remove), a builds
tab (per-project build list with live streamed output), and a logs tab
(deployment picker over a live runtime-log tail). marks c2/d2/f shipped
in the gap plan.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): fat-vs-thin runtime spike + fleet re-release pipeline

gaps.md e4 (the plan's ⭐ decide-now item). spike package
(spikes/runtime-versioning): the analysis — user functions execute
inside ShardDO and workerd has no dynamic code loading, so true-thin is
a distributed-transaction redesign, not a packaging change — plus live
probes for the three deciding hypotheses (cross-script DO bindings
under wfp, callback per-hop cost vs a 1ms viability line, fat-path
patch throughput arithmetic). provisional call: fat + pinned runtime +
automated forced re-release. that pipeline ships here too:
deployments record their runtimeVersion, and src/fleet/upgrade.ts
plans canary-first batches and halts on a dirty canary or breached
failure rate — a runtime patch becomes a paced batch job over the
existing build + health-gated release machinery.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): ring-2 pass — harden, close seams, finish product edges

security: github installations move to a staged-claim model (webhook
stages, owner/admin claims; recordPush only accepts claimed
installations and caps in-flight builds), domains.add enforces the
customDomains entitlement, and audit coverage lands for domains,
rollback, deletion requests, installation claims, and both suspension
crons. seams: build → deploy handoff via the runner's release port
(failed release keeps the artifact), stale-build self-healing cron,
superseded-release pruning (retain 3/project), and server-built pr
previews through the same pipeline. product: per-environment secrets
(all/production/preview/dev with kind-over-shared resolution + studio
picker), rollback button, suspension/deletion banners, org rename,
member role change (last-owner protected), project rename.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): switch billing to creem as merchant of record

Resolves gaps.md c3: creem (via @lunora/payment/creem) replaces the
stripe adapter as the platform's payment provider. As a merchant of
record it is the legal seller and calculates/collects/remits sales
tax/vat across 190+ jurisdictions, so the platform never inherits
worldwide tax compliance. Swaps the adapter wiring in the shard config
(CREEM_API_KEY / CREEM_WEBHOOK_SECRET / CREEM_TEST_MODE for the
sandbox), the webhook route + action to the creem-signature header,
the studio copy to creem product ids and hosted portal, and the docs.
Entitlements, dunning, plans, and quota enforcement are unchanged —
they ride the provider-agnostic subscriptions store.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): prepaid-credits overage billing core for creem

Verified against creem sdk 1.5.3: products are recurring/onetime only —
no metered subscription pricing — but creem ships a first-party credits
ledger (per-customer accounts, idempotent credit/debit by reference)
built for api metering. Overage is therefore prepaid: orgs buy credit
packs (one-time mor sales, tax handled by creem) and the platform
debits usage beyond the plan's included quota. Ships the pure core
(included quotas per plan, cost-plus overage rates, watermark-delta
debits with crash-safe idempotent references, exhausted → the existing
c1 suspension path, never negative), the overageDebits watermark table
with forward-only advancement, and 10 tests. The live credits api
wiring (CreditsLedgerPort) is the remaining 🌐 piece.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): creem credits-ledger adapter and fleet overage reconciliation

Completes the api/token-metering implementation over creem's
customerCredits api: a structural ledger adapter (balance reads via
bigint-safe strings, debits with the idempotent watermark reference,
missing account → null and never debitable), applyCreditPurchase for
the billing webhook (first purchase creates the account seeded with the
pack; later ones credit with the payment id as reference), the
organizations.creditsAccountId linkage (never overwritten once set),
and reconcileAllOverages — the fleet driver with per-org failure
isolation, watermark-advance strictly after a successful debit, and
exhausted balances handed to the c1 suspension hook.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): studio ux pass — usage meters, daily chart, command palette

Ring 3, patterns from the maple teardown (fsl-licensed observability
platform — ideas only, all code our own). usage tab: included-vs-used
plan-quota meters (amber at 80%, red past allowance, honest prepaid-
credits overage label) + a per-day request-volume chart over the new
usage.series query, rendered with a zero-dependency svg bar chart.
adds a ⌘k command palette (tab navigation + actions, substring match,
arrow/enter/escape keyboard flow, state reset by remount) wired into
the org dashboard. gaps.md gains the ranked ring-3 backlog (alerting
pillar, health charts, log-viewer upgrade, design tokens, onboarding
checklist, mcp surface, integrations hub).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): keep cron triggers within cloudflare cap

The control-plane Worker declared 4 distinct cron expressions (0 */1, 0
*/6, 0 */12, */1) — one over Cloudflare's hard limit of 3 Cron Triggers
per Worker, which would reject the deploy.

The lone 0 */12 trigger existed solely for "purge deleted organizations".
Fold that job into the existing 6h bucket: the purge gates on each org's
own 30-day retention cutoff, so a tighter cadence only shortens erasure
latency — it never erases early. Codegen drops the 0 */12 trigger, leaving
exactly 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01423xZDDqzhQ5D79vy25huF

* feat(cloud): observability ingest pipeline — issues + incidents (Phase 3) (#140)

* feat(cloud): add the observability ingest pipeline (issues + incidents)

Phase 3 of the observability plan — durable, cross-deployment monitoring
in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport.

- ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant
  `otlpSink` and the container exporter, decodes the error spans
  (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents
  through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous
  — the cloud app has no queue producer binding, so ingest inserts to D1
  directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the
  plaintext admin token.
- store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with
  `@lunora/fingerprint` (the same hash the local Studio computes, so a local
  Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts`
  member-authorized read/triage functions. A `TelemetryStore` adapter
  (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a
  guarded Pipeline→R2 archive, each a no-op without its binding.
- dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the
  `logStreams` entitlement, wired into `OrganizationDashboard`.
- bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket.

Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on
the cloud branch; the graft folds away once Phase 1 lands on alpha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* feat(cloud): observability alerts — rules + delivery (Phase 4) (#141)

* feat(cloud): add observability alerts — rules, firing + delivery

Phase 4 of the observability plan (the "watches while you sleep" tier),
stacked on the Phase 3 ingest.

- schema: `alertRules` (name, target issue/incident, threshold, channel
  email/webhook, destination, enabled) + `alerts` (fired-alert audit trail
  with firing→delivered state, notification denormalized).
- firing: the telemetry `ingest` mutation loads the org's enabled rules and
  fires each the first time a source's count crosses its threshold
  (`before < threshold <= after`, so exactly once), inserting a `firing`
  alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts`
  (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`.
- delivery: the `/v1/telemetry` edge handler delivers fired alerts
  best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps
  them delivered — never blocking or failing ingest.
- functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list,
  markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered).
- dashboard: `AlertsSection` (manage rules + recent fired alerts), gated
  behind the `logStreams` entitlement, wired into `OrganizationDashboard`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* fix(cloud): validate webhook alert destinations against SSRF

An alert rule's webhook `destination` is `fetch`ed by the control plane
when the alert fires, so an owner/admin could otherwise aim it at internal
infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) —
server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https
only, public host, no embedded credentials, no loopback/private/link-local
IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in
`deliverAlert` (never fetch an unsafe target — defense in depth for any
rule created before this guard). String-level, so it can't defeat DNS
rebinding, but it blocks the direct-address cases. Unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cloud): harden webhook SSRF guard

Two SSRF gaps in the Observability alert delivery path:

- deliverAlert followed webhook redirects, so a destination that passes
  isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the
  metadata IP). Set redirect: "manual" and reject 3xx responses.
- isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which
  the URL parser compresses to ::ffff:7f00:1) and the unspecified
  address (::) through. Reject the whole ::-prefixed non-global class.

Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already
blocked via WHATWG URL normalization; added as regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cloud): AI incident triage (@lunora/ai) — Phase 4C (#142)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(runtime): use LunoraError not undefined CirrusError in cloud endpoints

The scheduled-tick and queue-dispatch admin endpoints threw `new
CirrusError(...)`, a class that exists nowhere in the repo, so the file
failed to type-check (TS2304). The intended class is `LunoraError`, already
imported and used throughout the file.

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

* feat(cloud): full tenant log management with structured fields + trace correlation

Consume the structured, trace-correlated logs the framework now emits
(shared/log-event.ts) — the cloud log path kept only 3 severities + a flat
line and had no producer. Closes the framework side of Maple gap #2.

- Producer (GAPS.md B2, the missing piece): src/tail/worker.ts — the
  dispatch-namespace tail worker decodes each tenant `{source:"lunora",
  type:"log"}` console event (src/tail/parse.ts, pure + unit-tested), groups
  them per script, and POSTs batches to POST /v1/logs/tail. Holds one platform
  secret (LUNORA_TAIL_SECRET), not per-org deploy keys; the route resolves
  scriptName → org (logs.orgForScript) and stores via logs.ingestInternal.
  Deployed from tail.wrangler.jsonc.
- Store: tenantLogs widened to the full LogEvent shape — 7-tier severity,
  message, structured fields, functionPath, traceId/spanId, userId, shardKey —
  plus (scriptName, createdAt) and (org, traceId) indexes.
- Query: logs.list gained server-side levels/functionPath/traceId/search
  filters + a cursor and bounded limit, newest-first.
- UI: the studio Logs tab renders severity chips (filter), search, structured
  fields, and a short trace id per line.

Still 🌐: the provisioner setting tail_consumers on tenant scripts, an e2e run,
and correlating error/fatal lines to OTLP Issues by traceId (follow-up).

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

* fix(cloud): provision tenant bindings so deployed workers boot

The deploy handler built the provisioner spec with an empty binding set
(`bindings: {}`), so every uploaded tenant Worker was created with no
Durable Object binding and no `new_sqlite_classes` migration tag. A real
Lunora app always exports ShardDO, so it could never boot — the deploy
pipeline could only ship a binding-less worker.

The deploy request now carries the app's binding manifest (DO classes,
optional per-tenant D1/R2) which the CLI reads from `wrangler.jsonc`, and
the handler normalizes it to a spec that always includes the ShardDO
floor even when a caller under-declares or omits it. Malformed entries
are dropped and the DO list is capped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): tear down Cloudflare scripts for destroyed deployments

The lifecycle crons (cleanupExpiredPreviews, pruneSuperseded,
organizations.purgeDeleted) only transitioned a deployment to `destroyed`
— nothing ever deleted the Cloudflare dispatch script, so dispatch
namespaces grew unboundedly (the leak GAPS.md Ring-2 flagged as closed).

Add a `teardownAt` marker and a pure, port-injected `runTeardownSweep`
(per-target failure isolation, crash-safe idempotent off the marker),
wired into the control-plane Worker's scheduled() handler on the
hourly/6-hourly buckets — right after the crons that mark rows destroyed.
No-ops without Cloudflare credentials.

Per-tenant D1/R2 teardown-by-id still needs resource-id persistence and
is left as a follow-up; script deletion is the load-bearing fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): fold Analytics-Engine usage into the metering ledger

The dispatcher wrote one AE data point per tenant request, but nothing
ever read them back — createHttpAnalyticsReader had no caller, so
`platformUsage` only held what tenants self-report over POST /v1/usage
(nothing, in practice). Spend caps, the usage summary, and the usage
chart therefore evaluated an empty ledger.

Add a per-cell `usageReadAtMs` checkpoint and a pure, port-injected
`runUsageRollback` that delta-reads AE (`timestamp > checkpoint`),
attributes each dispatch script to its org/deployment, and appends
`requests` rows — then advances the checkpoint so re-runs never double
count. A per-row ledger failure is dropped rather than retried (under-
count, never double-bill — the same fail-safe as usage.rollup). Wired
into scheduled() on the hourly/6-hourly buckets; no-ops without
Cloudflare credentials.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): build-queue dispatcher (claim → run → drain)

`builds.claimNext` had no caller: enqueued builds sat untouched until the
24h expiry cron failed them with "no build runner picked this up". Add
the missing claim→run loop as a pure, port-injected `runBuildDispatch`
(bounded per-tick drain; a failed build never aborts the drain), fully
unit-tested against the runner ports.

Production activation stays gated on the runner's 🌐 seams — `execute`
(a throwaway Cloudflare Container running `lunora build`) and
`fetchSource` (GitHub App tarball) — which need live container infra, so
the dispatcher is not yet wired into scheduled(): claiming builds with no
executor would only burn them. This lands the verified logic so the
remaining work is purely the container seam, not the orchestration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* docs(cloud): split GAPS legend into wired vs pure-module (🧩)

The single ✅ conflated "tested pure function exists" with "feature runs".
Add a 🧩 marker for tested-but-uncalled modules, a dated wiring-pass
section covering the four gaps just addressed, and correct the two most
misleading inline entries:

- A3 builds: the claim dispatcher now exists (was missing); only the
  container execute() seam remains 🌐.
- C3 overage credits: reconcileAllOverages / applyCreditPurchase have no
  production caller (verified) — scheduling + webhook mapping are code
  (🔨), not credentials (🌐), so the honest status is 🧩, not "✅ core".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): boot-time route classification scanner

Port of Openship's route-scanner idea (Apache-2.0) to the /v1 router. The
control-plane routes each did inline auth then delegated to a self-
authorizing function, but nothing forced a *new* route to be classified —
an unclassified endpoint could ship silently and read as protected.

Every route now carries an explicit RouteSpec.auth (deployKey / session /
webhookHmac / tailSecret / adminToken / public), and
assertRoutesClassified runs when createDeployRouter builds the table: a
missing/unknown classification, a public route with no reason, or a
duplicate (method, path) throws at construction — the Worker fails to
start rather than serving an unclassified route. The flat dispatch tables
are derived from the one checked list (GET + POST unified). The spec's
opt-in `mcp` field is the allowlist the MCP surface will read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): MCP surface generated from the route registry

Port of Openship's "MCP tools derived from the route registry" idea
(Apache-2.0). A `/v1/mcp` JSON-RPC endpoint (tools/list + tools/call)
exposes only routes that opt in via RouteSpec.mcp, and every tool call
dispatches back through the real router carrying the agent's own bearer
credential — so it runs the identical auth + rate-limit + handler +
function-authz path as any HTTP caller; the MCP layer grants no privilege.

A hard deny-list (buildMcpTools) guarantees token/secret/tenant-access
routes (/v1/secrets, /v1/admin, /v1/invitations/send, /v1/logs/tail) and
the surface itself (/v1/mcp) can never become tools even if mis-annotated
— the same scope-escape guard Openship applies to tokens/auth/mcp. Only
bearer-callable (deployKey/adminToken) opted-in routes are eligible;
session/webhook routes are excluded. deployments.rollback is the first
tool exposed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* chore(codegen): regenerate _generated for teardownAt + usageReadAtMs

Keeps the emitted dataModel/shard/drizzle types consistent with the new
deployments.teardownAt and cells.usageReadAtMs schema columns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): tear down tenant D1 + R2, and test the sweep glue

Extends resource teardown past the dispatch script (#2). The lifecycle
sweep now also deletes the per-tenant D1 database and R2 bucket, resolved
by the same naming convention the provisioner creates them under (shared
tenantD1Name / tenantR2Bucket helpers — no drift, no new persistence).
New CF API methods: findD1DatabaseByName + deleteD1Database (uuid) and
deleteR2Bucket (name). Script + D1 delete are retryable; R2 is best-effort
(a non-empty bucket needs an S3-API object purge the teardown context
lacks — logged, left for follow-up). D1 (every .global() app has one) and
empty R2 buckets are now fully reclaimed.

Also extracts the scheduled() sweep glue into testable port-builders
(#4): teardownPorts + usageRollbackPorts over a structural ControlPlaneDb,
so the row→target mapping, the teardownAt stamp, the ledger insert, and
the per-cell checkpoint are unit-tested against a fake store instead of
living untested inside server.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): carry cronSpecs + bindings from wrangler on deploy

The cron fan-out read live deployments' cronSpecs, but nothing ever
populated them: deployments.create accepted the field yet the deploy
handler/router never passed it, so readCronTargets always returned [] and
the entire §2.4 tenant-cron fan-out had no data source (#3).

Add parseWranglerManifest — a pure reader that extracts the binding
manifest (DO classes / D1 / R2) and cron expressions from a tenant's
wrangler.jsonc — and thread cronSpecs through the deploy request →
handler → create mutation. The deploy client + CLI now forward both
bindings and cronSpecs, so a real deploy provisions what the Worker needs
and registers the crons the fan-out drives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): synthetic uptime monitoring with alerting

Adds external-vantage uptime to the control plane's Observability tier — the
piece a deployment can't self-report (if it's down, it can't say so).

- Probe: pure probeDeployment (generalizes the deploy-time healthCheck — GET,
  sub-500 = up, latency + timeout, never throws), a consecutive-failure state
  machine, and a summarizer, all unit-tested (src/uptime/probe.ts).
- Sweep: runUptimeSweep over injected ControlPlaneDb ports (mirroring the
  teardown/usage sweeps) probes every live deployment, records a uptimeChecks
  row, advances uptimeState, and fires an "uptime" alert the first time a
  deployment's failures cross a rule threshold — reusing crossesThreshold,
  renderAlert, and the alerts table/delivery pipeline (src/uptime/sweep.ts).
- Edge: server.ts scheduled() runs the sweep on the every-minute tick and
  delivers fired alerts over their channel (webhook/email), stamping the outcome.
- Alerts gain an "uptime" target (schema + createRule + renderAlert), so users
  configure "page me when my deployment is down" alongside issue/incident rules.
- Read side: lunora/uptime.ts (summary + recent queries, retention prune cron)
  backs a new Uptime dashboard section.

Cron triggers stay at 3 expressions (prune rides the 6h bucket, the probe rides
the existing every-minute tick). Full suite: 272 tests, lint:types clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019M6G6CAoLVrQMxDYg7BWq2

* fix(cloud): address thermo review of uptime monitoring

Security/correctness (branch audit):
- SSRF: the sweep fetched a …
prisis added a commit that referenced this pull request Aug 19, 2026
…e 3) (#140)

* feat(cloud): add the observability ingest pipeline (issues + incidents)

Phase 3 of the observability plan — durable, cross-deployment monitoring
in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport.

- ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant
  `otlpSink` and the container exporter, decodes the error spans
  (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents
  through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous
  — the cloud app has no queue producer binding, so ingest inserts to D1
  directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the
  plaintext admin token.
- store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with
  `@lunora/fingerprint` (the same hash the local Studio computes, so a local
  Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts`
  member-authorized read/triage functions. A `TelemetryStore` adapter
  (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a
  guarded Pipeline→R2 archive, each a no-op without its binding.
- dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the
  `logStreams` entitlement, wired into `OrganizationDashboard`.
- bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket.

Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on
the cloud branch; the graft folds away once Phase 1 lands on alpha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* feat(cloud): observability alerts — rules + delivery (Phase 4) (#141)

* feat(cloud): add observability alerts — rules, firing + delivery

Phase 4 of the observability plan (the "watches while you sleep" tier),
stacked on the Phase 3 ingest.

- schema: `alertRules` (name, target issue/incident, threshold, channel
  email/webhook, destination, enabled) + `alerts` (fired-alert audit trail
  with firing→delivered state, notification denormalized).
- firing: the telemetry `ingest` mutation loads the org's enabled rules and
  fires each the first time a source's count crosses its threshold
  (`before < threshold <= after`, so exactly once), inserting a `firing`
  alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts`
  (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`.
- delivery: the `/v1/telemetry` edge handler delivers fired alerts
  best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps
  them delivered — never blocking or failing ingest.
- functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list,
  markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered).
- dashboard: `AlertsSection` (manage rules + recent fired alerts), gated
  behind the `logStreams` entitlement, wired into `OrganizationDashboard`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* fix(cloud): validate webhook alert destinations against SSRF

An alert rule's webhook `destination` is `fetch`ed by the control plane
when the alert fires, so an owner/admin could otherwise aim it at internal
infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) —
server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https
only, public host, no embedded credentials, no loopback/private/link-local
IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in
`deliverAlert` (never fetch an unsafe target — defense in depth for any
rule created before this guard). String-level, so it can't defeat DNS
rebinding, but it blocks the direct-address cases. Unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cloud): harden webhook SSRF guard

Two SSRF gaps in the Observability alert delivery path:

- deliverAlert followed webhook redirects, so a destination that passes
  isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the
  metadata IP). Set redirect: "manual" and reject 3xx responses.
- isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which
  the URL parser compresses to ::ffff:7f00:1) and the unspecified
  address (::) through. Reject the whole ::-prefixed non-global class.

Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already
blocked via WHATWG URL normalization; added as regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
prisis added a commit that referenced this pull request Aug 19, 2026
…rts, sessions, dashboards (#179)

* feat(studio): browse the durable log archive in the Logs panel (#155)

* feat(studio): browse the durable log archive in the Logs panel

Add a third "Archive" feed to the studio Logs panel that reads the durable
ctx.log archive pipelineLogSink writes to R2 (Iceberg / R2 Data Catalog).

- @lunora/runtime: a new admin-gated `/_lunora/admin/logs/archive` route
  (`log-archive-admin-routes.ts`) runs `createPipelineLogReader` server-side —
  the R2 SQL token stays on the worker, only decoded `{ rows, nextCursor }`
  reaches the browser. Reads creds from env (`R2_SQL_*`, `CLOUDFLARE_ACCOUNT_ID`
  fallback) + the table from a new `logArchive` WorkerOption. Fails closed with
  `LOG_ARCHIVE_NOT_CONFIGURED` when unwired.
- @lunora/client: `queryLogArchive(query)` method + re-exported PipelineLog*
  wire types (owned by @lunora/runtime).
- @lunora/studio: a self-contained `ArchiveFeed` (function/user/min-level
  filters, keyset "Load more", a "not configured" empty state distinct from an
  error) rendered under the new Archive tab; `errorCode` helper.
- Docs + API snapshots updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* refactor(studio): drop try/finally in ArchiveFeed for React Compiler

The React Compiler bails on a `try` with a `finally` clause (React Doctor
`react-hooks-js/todo`), so the component missed automatic memoization. Reset
`loading` in each branch instead, matching the repo's no-`finally` pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* refactor(studio): address thermos review of the archive feed

Code-quality + correctness follow-ups from the two-pass review:

- Extract the duplicated LEVEL_VARIANT map into `log-level-variant.ts`, shared by
  the live Logs feeds and the Archive feed (restores the exhaustive
  `Record<LogLevel, BadgeVariant>` type — drops the `?? "outline"` fallback).
- Collapse the four `view !== "archive"` readout guards in logs-panel into one
  `view === "archive" ? <ArchiveFeed/> : <>…</>` branch.
- Drop the `JSON.parse(JSON.stringify(baseQuery))` round-trip in the fetch effect
  — pass `baseQuery` directly, keying the effect on `querySignature`.
- Guard `loadMore` against a cross-filter race: a page-2 fetch that resolves
  after a filter change is dropped (via `activeSignatureRef`) instead of
  appending stale rows / overwriting the cursor.
- Show a "Loading…" placeholder on the initial fetch instead of a blank panel.
- Type `minLevel` state as `"" | ContextLogLevel` (removes a cast).
- Move the `LOG_ARCHIVE_NOT_CONFIGURED` sentinel to `shared/log-archive.ts` so
  runtime and studio share one source of truth with no dependency edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* refactor(studio): address CodeRabbit review of the archive feed

- Fold ArchiveFeed's five fetch-related useState into one useReducer, so each
  fetch transition (loading / loaded / append / notConfigured / failed /
  pageFailed) is a single dispatched action (React Doctor prefer-useReducer).
- Use the imported `ChangeEvent` type instead of the `React.*` namespace, matching
  logs-panel.tsx.
- Gate the toolbar `LiveError` on `view !== "archive"` so the disabled Errors
  feed's live-connection state can't leak into the (WS-less) Archive tab.

The CodeRabbit "cast env to LogArchiveEnvironment" suggestion is intentionally
skipped: `env ?? {}` narrows to `{}`, which is assignable to the all-optional
LogArchiveEnvironment (lint:types is green), and eslint's
no-unnecessary-type-assertion rejects the cast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

* polish(studio): clear prior error on a fresh archive fetch

Thermos re-review nice-to-have: the reducer's `loading` action now clears
`error`/`notConfigured` (matching the kv reducers' `submitStart`), so retrying
after a failure shows the loading placeholder instead of the stale error line.
Rows are kept, so paging / filter-change refetches don't blank the table.

(Kept `default: return state` to stay consistent with the existing kv reducers
rather than introduce a one-off `unreachable` exhaustiveness guard.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(release): @lunora/runtime@1.0.0-alpha.32 [skip ci]\n\n## @lunora/runtime [1.0.0-alpha.32](https://github.com/anolilab/lunora/compare/%40lunora%2Fruntime%401.0.0-alpha.31...%40lunora%2Fruntime%401.0.0-alpha.32) (2026-07-21)

* chore(release): @lunora/client@1.0.0-alpha.26 [skip ci]\n\n## @lunora/client [1.0.0-alpha.26](https://github.com/anolilab/lunora/compare/%40lunora%2Fclient%401.0.0-alpha.25...%40lunora%2Fclient%401.0.0-alpha.26) (2026-07-21)

* **@lunora/runtime:** upgraded to 1.0.0-alpha.32

* docs: add cirrus cloud platform plan

Reverses the managed-deploy-plane won't-do (VOID-TEARDOWN.md §0/§6,
CONVEX-PARITY.md #23) with a scoped managed tier: Workers for Platforms
data plane, Convex-shaped product model (teams/projects/prod+dev+preview
deployments), PartyKit-style managed-vs-BYO CLI split, and a phased
roadmap starting with remote-binding dev. Synthesized from a repo
inventory, a Convex Cloud teardown, and a GitHub/Cloudflare-primitives
survey.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: fold supabase platform teardown into cloud plan

Adds Supabase as a reference model: the OSS/proprietary cut line,
IS_PLATFORM single-codebase studio pattern, Branching 2.0 preview DX
(and its pain points to fix: empty branches, hourly branch billing
outside spend caps), the Management API + OAuth-apps growth channel,
and the structural cost advantage WfP gives over per-project VMs.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add wfp constraints, eject path, spike checklist

Gap review of the cloud plan: documents that cron triggers are
silently dropped for namespaced user Workers (with SchedulerDO
alarm-based fan-out mitigation), queue-consumer and send_email
verification items, KV account-limit multiplexing, EU jurisdiction
toggle, a cirrus-eject portability command built on existing
export/import RPCs, namespace-wide observability reuse, managed
backups + abuse controls in Phase 4, and a Phase 1 constraint spike.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: verify cloud plan claims against cloudflare docs

Fact-checked every Cloudflare claim in CLOUD-PLAN.md against the
official docs: WfP pricing, KV/D1/R2 account limits, DO/R2
jurisdictions vs D1 location hints, CF for SaaS hostname pricing, and
remote-bindings GA versions all confirmed. Adds three newly verified
constraints: no gradual deployments for user Workers (rollback must be
platform-side bundle re-upload), the 1200-req/5-min account API rate
limit on provisioning, and outbound-Worker TCP/DO interception
trade-offs.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add cell-based scaling architecture to cloud plan

Answers how the managed tier scales without hitting account limits or
risking platform-wide blocks: script-resident tenant state with lazy
inference-driven provisioning, multi-account cells with cell IDs baked
into identifiers from day one, a per-cell API token-bucket scheduler,
a tenancy graduation ladder up to managed-BYO and the Tenant API, and
abuse containment to keep tenant abuse from looking like platform
abuse.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: scope alchemy to cell bring-up, not tenant deploys

Records the provisioning-engine decision: the per-tenant deploy path
stays hand-rolled on cloudflare-typescript (control-plane DB as the
single source of truth, cell scheduler, progress events, rollback
artifacts); Alchemy (pre-1.0, v2 rewrite underway, no confirmed
dispatch-namespace resource) is a candidate only for low-cardinality
cell bring-up IaC, with Terraform/Pulumi as the fallback.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: correct alchemy facts (dispatch-namespace resource, v0.93)

Re-checked Alchemy against GitHub/npm: it is v0.93.12 (Apache-2.0) and
does ship a dispatch-namespace (Workers for Platforms) resource — my
earlier 'lacks a confirmed dispatch-namespace resource' was wrong and
'v0.9x' undersold it. Recommendation is unchanged (hand-roll the
per-tenant deploy, use Alchemy for cell bring-up) but now rests on the
real reason — source-of-truth shape and deploy-orchestration concerns,
not capability — with a re-evaluation trigger at a stable 1.x.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: adopt alchemy as the provisioning engine

Decision: Alchemy is the provisioning engine across cell bring-up,
per-tenant managed deploy, and BYO. Verified it ships DispatchNamespace/
Worker/D1/R2/DO resources plus a built-in D1StateStore and runs inside a
Worker (await alchemy(scope) -> finalize/destroy). Backing each tenant
scope with the control-plane D1 collapses the two-sources-of-truth
concern into one store. The per-cell rate-limit scheduler now paces
finalize() runs; bundling stays in the Vite pipeline; rollback re-
converges to a prior R2-retained bundle. Risk of a 0.x dependency
contained behind a @cirrus/provision adapter with cloudflare-typescript
as fallback; spike open items recorded.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: build on alchemy v2 (alchemy@next, 2.0.0-beta.55)

Per decision to start on the v2 line: target alchemy@next (verified
2.0.0-beta.55, Effect-based) to avoid a v1->v2 migration mid-build,
with v1 0.93.x as the named fallback. Records the trade-offs (beta
churn, Effect pulled into the control-plane tree, quarantined behind
the @cirrus/provision adapter) and turns the unverified v2 facts
(DispatchNamespace resource + D1/DO state store, confirmed on v1 only
since docs/CDN were unreachable) into hard Phase 1 spike gates with
v1 fallback per surface.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: commit fully to alchemy v2, drop v1 fallback

Remove the v1 (0.93.x) fallback hedging throughout: v2 (alchemy@next,
2.0.0-beta.55) is the engine outright. Spike gates remain but now
resolve via owned shims or upstream contributions rather than retreat
to v1; a hard unresolvable 'no' escalates the engine decision instead
of silently dual-tracking.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: add forgotten must-haves + fleet runtime-versioning risk

Gap pass on the cloud plan. New risk #8 (the load-bearing one): the
Cirrus runtime is bundled into each tenant Worker, so a security patch
means redeploying the whole fleet unless the tenant Worker is made
'thin' against a central runtime — a fat-vs-thin decision that must be
made before Phase 1 since it shapes the bundle format, deploy API, and
vite emit. New section 7 collects launch-blocking gaps the plan had
assumed away: control-plane DB durability/DR, cross-cell disaster
recovery, secrets-at-rest + cell-token custody, frontend-hosting scope,
AUP + bill-shock/cryptomining controls, billing/MoR/tax, GDPR
processor/DPA/SOC2, account offboarding + right-to-erasure, platform
self-observability + status page, dispatcher canary, and a staging cell.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): scaffold control-plane app built on cirrus

First implementation step from CLOUD-PLAN.md: a new apps/cloud workspace
app that dogfoods Cirrus as the platform's own control-plane backend.

- cirrus/schema.ts: control-plane data model (cells, organizations,
  members, projects, deployments, deployKeys, auditLog), all .global()
  (D1) — the plan's 'Worker + D1' control plane.
- cirrus functions: organizations/projects/deployments/cells/deploy-keys
  (create/list/issue/updateStatus), with owner seeding + audit trail.
- src/server.ts: control-plane Worker entry wiring D1-backed global tables.
- src/provision.ts: the @cirrus/provision seam — the sole coupling to the
  Alchemy v2 engine (stub that rejects until the Phase 1 spike wires it).
- configs (package.json/tsconfig/project.json/wrangler.jsonc/eslint/vitest),
  README, and a provision test.

Verified: codegen clean (no advisories), eslint clean, tsc --noEmit
passes, vitest green.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add deploy-orchestration core

Builds the next control-plane layer on the scaffold's Provisioner seam,
all pure/testable (no live Cloudflare needed):

- token-bucket.ts: per-cell API budget (§2.5), models CF's 1200/5min
  account limit; deterministic + clock-injectable.
- scheduler.ts: CellScheduler paces/serializes provisioner work against
  the bucket with priority ordering + a concurrency cap.
- orchestrator.ts: runDeployment state machine emitting queued →
  provisioning → live/failed progress events (§2.2); destroyDeployment
  for preview-TTL/project teardown.
- keys.ts: deploy-key format/parse/hash helpers; deploy-keys.ts mutation
  refactored to use them (one tested place for the format + SHA-256).

17 tests across 5 files; eslint + tsc --noEmit clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add org authorization + deploy-key lifecycle

Closes gaps found reviewing the control plane:

- authz.ts: assertMember(ctx, orgId, roles?) — the org ACL gate. Every
  org-scoped function now verifies the caller is a member with a
  permitted role, closing an IDOR hole where any signed-in user could
  read/mutate any org by passing its id. Applied across projects,
  deployments, deploy-keys.
- members.ts: list / add / remove so memberships can actually be granted
  (owner is seeded on org create; admins/owners manage the rest).
- deploy-keys: verify (the deploy API's auth path — match by SHA-256,
  reject revoked, bump lastUsedAt, return the DB-authoritative target)
  and revoke (leaked-key mitigation); lastUsedAt/revokedAt are now live.
- deployments: create checks the project belongs to the org; updateStatus
  loads the deployment and gates on its org (documented as the system
  seam for the orchestrator).

codegen clean, eslint + tsc --noEmit clean, 17 tests pass.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): deploy API endpoint, deploy-key auth, handler tests

Lands the three remaining pieces together:

- Deploy API: POST /v1/deploy mounted via the httpRouter seam (src/deploy/
  router.ts) → pure handler (src/deploy/handler.ts) authenticates the bearer
  deploy key, records a queued deployment, drives runDeployment through the
  per-cell scheduler, and streams NDJSON progress (accepted→queued→
  provisioning→live/failed→done), patching status per phase.
- Auth path: investigation showed internalMutation is unreachable from the
  HTTP action-context dispatch (no system flag → RPC 404), so verify/
  updateStatus stay public; instead added deploy-key authorization
  (authz.authorizeDeployKey) and a dual-path (member session OR deploy key)
  on deployments.create/updateStatus, so CI deploys need no user session.
  Corrected the stale 'should become internalMutation' comments.
- Tests: handleDeployRequest (401/403/400 + success and failure streaming +
  status transitions) and authz (assertMember + authorizeDeployKey) via a
  fake ctx. 28 tests / 7 files; eslint + tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): refresh status — deploy API + auth now in place

* chore(cloud): track generated schema snapshot

Matches the apps/playground convention — .cirrus-schema.json is the
codegen schema snapshot used for migration/drift detection and is
committed, not gitignored.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): preview lifecycle, GitHub webhook, deploy client (Phase 1/2)

Phase 2:
- Preview deployments are TTL'd: deployments.create stamps expiresAt for
  kind=preview (src/deploy/preview.ts: deterministic previewScriptName +
  5-day previewExpiry); an hourly cron (cirrus/crons.ts -> internal
  deployments.cleanupExpiredPreviews) marks expired previews destroyed.
  Worker gains scheduled(); wrangler cron trigger added.
- GitHub webhook (src/github/webhook.ts): HMAC-SHA256 verify + pull_request
  -> preview-intent parsing, mounted at POST /v1/github/webhook.

Phase 1:
- Deploy client (src/deploy/client.ts): the cirrus-deploy core — POSTs to
  /v1/deploy and consumes the NDJSON progress stream.

13 new tests (41 total); codegen/eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: update roadmap — Phase 0 shipped, Phases 1-2 status

Phase 0 (remote-binding dev) is already implemented in the framework
(@cirrus/config remote-bindings + @cirrus/vite plugin + cirrus dev;
30 tests) — corrected from 'not started'. Phases 1-2 marked
substantially-built with the live-Cloudflare-dependent remainder
(Alchemy provisioner, dispatcher, e2e validation) called out.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): team invitations + billing quotas; repo-extraction guide (Phase 3/4)

Phase 3 (hosted studio sliver):
- Team invitations (cirrus/invitations.ts + invitations table): invite/list/
  revoke/accept, single-use SHA-256-hashed tokens (plaintext mailed once),
  owner-admin gated; accept-by-token adds the caller as a member.

Phase 4 (billing sliver):
- Plans + quota entitlements (src/billing/plans.ts) on @cirrus/payment's
  entitlements model — free/pro/enterprise limits + feature flags, with
  effectiveLimit/withinQuota and a free-tier fallback for non-subscribers.

Portability (move to a private repo):
- EXTRACT.md documents the mechanical extraction; audit confirms the app
  imports only published @cirrus/* entry points (no monorepo-internal reaches).

6 new tests (47 total); codegen/eslint/tsc clean; secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): real Cloudflare provisioner, quota enforcement, webhook project resolution

Provisioner (Phase 1 — the big one): replaces the rejecting stub with a real
implementation over a typed Cloudflare REST port (src/cloudflare/api.ts):
deploy provisions per-tenant D1/R2, uploads the user Worker into the dispatch
namespace with binding + DO-migration metadata, applies secrets, returns the
bundle hash + routed URL; destroy deletes the script. Port-injected so it's
tested with a fake; plug in CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN to run.
(REST via fetch rather than the unverifiable alchemy@next beta — same seam.)

Quota (Phase 4): plans.ts gains planLimit/withinPlanQuota; projects.create and
members.add enforce the org plan's limits.

Preview automation (Phase 2): projects gain githubRepo + byGithubRepo lookup;
the webhook resolves the connected project and returns the preview script name.

Env documented (.dev.vars.example + wrangler vars). 50 tests; eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): dispatcher Worker + hosted-studio admin-RPC proxy (Phase 1/3)

Phase 1 — dispatcher Worker (the request-path front door): resolveTenant
maps {scriptName}.{appDomain} (and custom domains via injected lookup) to a
dispatch-namespace script; the worker forwards via env.DISPATCHER.get with
per-plan limits. Separate deployable (dispatcher.wrangler.jsonc).

Phase 3 — admin-RPC proxy: proxyAdminRequest authorizes org membership,
forwards the admin RPC to the tenant's /_cirrus/admin/* with that
deployment's admin token, and records an audit entry. Pure (deps injected).

7 new tests (57 total); eslint/tsc clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): cirrus login/link/deploy CLI commands (Phase 1)

Pure command logic over a ConfigStore + the deploy client: login persists the
API endpoint + deploy key, link binds a project, deploy streams a managed
deploy (requires login+link). File-backed store at ~/.cirrus/cloud.json for
the Node CLI; cerebro registration in @cirrus/cli calls these.

3 new tests (60 total); eslint/tsc clean; secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: roadmap status — control-plane backend feature-complete as code

All phases' backend code is built + unit-tested in apps/cloud (60 tests):
real REST provisioner, dispatcher, CLI, preview lifecycle, GitHub webhook,
team invitations, admin-RPC proxy, quota enforcement. Remaining items are
the ones needing live Cloudflare / external services / the studio UI.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): admin-proxy live wiring, usage metering, custom-hostname port (Phase 3/4)

Phase 3 — admin proxy mounted at POST /v1/admin: deployments now carry the
platform-minted tenant adminToken (set as the worker's CIRRUS_ADMIN_TOKEN
secret + stored on the row), deployments.adminTarget resolves {url, adminToken}
after asserting membership, and the router forwards to the tenant's
/_cirrus/admin/* with an audit-log.record entry.

Phase 4 — usage metering: usageEvents table + internal record mutation +
member summary query over a pure aggregateUsage roll-up. Custom hostnames:
CloudflareApi.createCustomHostname (Cloudflare for SaaS, zone-scoped REST).

Router refactored into per-route handlers. 64 tests; eslint/tsc/secret-scan clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs: roadmap — admin proxy mounted, usage metering + custom-hostname port added

* feat(cloud): add hosted studio react spa

Build the hosted-studio frontend for the Cirrus Cloud control plane: a
better-auth-gated React SPA served on one origin with the control-plane
Worker via @cirrus/vite.

- src/client: main/auth-client/Login, App auth gate, OrganizationList,
  OrganizationDashboard with tabs for projects, deployments, members,
  deploy keys, invitations, and usage; AsyncList loading/empty helper.
- Wire @cirrus/auth into src/server.ts (createAuth + cirrusD1Adapter,
  ensureMigrated, handleAuthRequest, authAdmin, resolveIdentity) and add
  AUTH_SECRET/AUTH_URL env + .dev.vars.example entries.
- Switch package scripts to vite (build/dev), add react/react-dom +
  @cirrus/react/@cirrus/auth deps, vite.config.ts, index.html, and the
  DOM lib + jsx in tsconfig.
- eslint: client section (filename-case, react-perf, void), browser
  globals; ignore vite.config.ts.
- Refresh README + CLOUD-PLAN status to reflect the studio UI.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): add billing, metering, and hardened auth

Billing on @cirrus/payment (§4): org id is the payment referenceId. Wire a
Stripe adapter into createShardDO({ payment }); add cirrus/billing.ts with
checkout/portal actions, entitlements/subscription reads (resolved through
CIRRUS_CLOUD_PLANS with a free-tier fallback), and a signature-verified
processWebhook mounted at POST /v1/billing/webhook. The studio gains a
Billing tab.

Platform metering (§4): rename the resource-metering table to platformUsage
(freeing usageEvents for @cirrus/payment's billing ledger), add a deploy-key
authenticated usage.ingest mutation + POST /v1/usage endpoint, and enforce
per-plan runtime limits in the dispatcher (limitsForPlan → DISPATCHER.get).

Auth hardening (§3) on @cirrus/auth/better-auth: mail-backed email
verification + password reset (@cirrus/mail), optional GitHub/Google OAuth,
admin/twoFactor/passkey plugins, built-in auth rate limiting, plus a per-IP
@cirrus/ratelimit cap on the /v1/* surface. Invitations now email the token
via POST /v1/invitations/send (never shown in the browser). The Cirrus
organizations/members model stays the single org source of truth (better-auth
organization plugin deliberately omitted).

Add deps (@cirrus/mail, @cirrus/ratelimit, stripe), tests for the router
routes + rate limiting + per-plan limits (69 total), and reconcile the README
+ CLOUD-PLAN status (the provisioner is a real Cloudflare REST impl, not a
stub).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): enforce entitlements, wire metering source, add secrets

Close the billing loose ends and add the metering source, tenant secrets,
and an audit-log view.

Entitlements (close loose end #1): quota is now enforced against live
subscription state (cirrus/entitlements.ts resolves from the synced
`subscriptions` table) rather than the static organizations.plan column —
projects/members creation call assertWithinQuota, so a Stripe upgrade raises
limits immediately with no column to sync.

Per-plan dispatch limits (close loose end #2): deployments.planForScript +
a bearer-gated GET /v1/tenants/plan endpoint + a cached plan resolver in the
dispatcher (createPlanResolver) wire resolvePlan, so runtime limits actually
scale per plan instead of always falling back to free.

Metering source: the dispatcher emits one Analytics Engine data point per
tenant request (src/metering/analytics.ts); a reader port + HTTP impl and an
hourly usage.rollup compaction cron complete the pipeline alongside the
existing /v1/usage ledger ingest.

Tenant secrets (§7): AES-256-GCM envelope encryption at the edge
(src/secrets/crypto.ts), a secrets table (ciphertext + IV only), store/list/
listEncrypted/remove functions, POST /v1/secrets, deploy-time materialization
into the tenant Worker, and a studio Secrets tab.

Studio: add Secrets + Activity (audit log) tabs; add audit-log.list.

Tests: crypto round-trip, plan resolver caching/fallback, entitlement quota,
analytics writer/reader (83 total). Docs + .dev.vars.example updated.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): address review findings (rollup atomicity, deploy failure, dedup)

Apply /review findings on the recent billing/metering/secrets work:

Correctness:
- usage.rollup: the D1 global backend has no multi-statement transaction, so
  the old insert-summed-then-delete-originals order could double-count (over-
  bill) on a mid-rollup crash. Reorder to delete the extras first, then patch
  the surviving row's total last — a crash can now only under-count, never
  leave a summed row beside surviving originals.
- deploy handler: a tenant-secret decrypt failure (corrupt secret / rotated
  key) threw inside the NDJSON stream and left the deployment stuck in
  `accepted`. Catch it and transition to `failed` with a status update.
- POST /v1/secrets: encryption/config failures (e.g. a malformed
  SECRET_ENCRYPTION_KEY) now return 500, not a misleading 403 (kept distinct
  from the membership 403 the store mutation raises); reject the reserved
  CIRRUS_ADMIN_TOKEN secret name up front instead of silently clobbering it.
- studio: drop the plan picker from org creation — limits now come from live
  subscription entitlements, so selecting a paid plan at create-time granted
  nothing. Orgs start free; upgrade via the Billing tab.

Cleanup:
- Extract the cross-org IDOR guard into authz.assertRowInOrg and call it from
  secrets/members/deploy-keys/invitations (was four byte-identical copies).
- Remove dead plans.ts exports planLimit/withinPlanQuota (superseded by
  entitlements-based quota); add a single highestPlan/PLAN_PRECEDENCE helper
  and use it in deployments.planForScript (was a hand-rolled tier ladder).
- Memoize the Stripe payment config per isolate (was rebuilt on every shard
  request that touches ctx.payments).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* test(cloud): validate websockets through dispatch (phase 1 spike)

Validate the hottest path — hibernated-WS subscriptions + per-invocation
limits through env.DISPATCHER.get() — the least-documented WfP case (risk #3).

- spikes/ws-dispatch/: a runnable harness for live validation on a real
  dispatch namespace. A framework-free hibernatable-WebSocket Durable Object
  (the exact primitive ShardDO uses: acceptWebSocket + webSocketMessage),
  deployable into the namespace, plus a zero-dep Node probe that drives it
  through the dispatcher and asserts: (1) the WS upgrade survives the dispatch
  hop (101 + live socket), (2) a hibernated server push (broadcast) reaches the
  socket — the mutation-to-subscription shape, (3) cpuMs-limit behaviour. The
  README documents deploy/run, pass/fail, and the expected results + caveats.
- __tests__/dispatcher-ws.test.ts: unit-pins the dispatcher forwarding
  contract (returns the tenant 101+webSocket response unchanged, applies
  per-plan limits, meters the upgrade once) — runs in CI, no infra needed.
- dispatcher worker: clarifying comments on WS pass-through + per-frame
  metering semantics. CLOUD-PLAN risk #3 now references the harness.

The dispatcher half is verified here (94 tests); the end-to-end behaviour
needs a live Cloudflare account + the Workers-for-Platforms add-on to run.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant cron fan-out through dispatch (wfp workaround)

Cloudflare drops triggers.crons for Workers in a dispatch namespace, so tenant
cron jobs never fire. Fan them out from the control plane (CLOUD-PLAN §2.4).

- @cirrus/runtime: add an admin-gated POST /_cirrus/scheduled tick endpoint
  that runs a cron expression's jobs through the SAME handleScheduled path the
  native scheduled() trigger uses (user crons + code crons + backup), so a
  platform can drive a namespaced tenant's crons over HTTP. (Dispatch stubs
  expose only fetch()/connect() — no scheduled()/queue() — so HTTP is the only
  transport in.)
- src/fanout/cron.ts: pure 5-field cron-expression matching (lists, ranges,
  steps, dom/dow OR semantics) + dueTicks + fanOutCron orchestration.
- control plane: capture each tenant's cronSpecs on the deployments row at
  deploy; an every-minute heartbeat cron (cirrus/fanout.ts) makes codegen emit
  the */1 trigger, and server.ts scheduled() reads live cron targets and ticks
  each due tenant via env.DISPATCHER.get(script).fetch('/_cirrus/scheduled')
  with the per-deployment admin token (kept in-process — never exposed). Adds
  the DISPATCHER binding to the control-plane wrangler.

Tests: cron matching, dueTicks, fanOutCron (103 cloud tests; 337 runtime
tests still green). Live validation on a dispatch namespace pending; queue
consumer fan-out is the remaining half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant queue-consumer fan-out through dispatch (wfp workaround)

WfP namespaced Workers can't be queue consumers, so tenant queue-backed work
(@cirrus/mail sends, scheduler queue-workpool) never drains. Fan it out from a
platform-owned consumer (CLOUD-PLAN §2.4) — the queue counterpart to the cron
fan-out.

- @cirrus/runtime: add a `queueHandler` option + an admin-gated
  POST /_cirrus/queue endpoint that reconstructs the batch and invokes it,
  returning the message ids to retry. (Dispatch stubs are fetch-only, so HTTP
  is the only transport into a namespaced tenant.)
- src/fanout/queue.ts: pure grouping of a shared-queue batch by the producing
  tenant's script (envelope `{ script, body }`) + fan-out orchestration that
  collects per-message retries and retries a whole group on delivery failure.
- control plane: the account-level Worker is the consumer — server.ts queue()
  drains the shared cirrus-tenant-queue, resolves each tenant's admin token
  in-process (never exposed), forwards sub-batches via
  env.DISPATCHER.get(script).fetch('/_cirrus/queue'), and acks/retries per the
  tenant reply. Adds the queues.consumers binding to the control-plane wrangler.

Tests: groupByTenant + fanOutQueue (108 cloud tests; 337 runtime tests green).
Live validation on a dispatch namespace + a producer-side script-tagging helper
remain.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): align with the lunora rebrand + reuse @lunora/analytics

Rebased onto alpha, which renamed the framework cirrus → lunora. Reconcile the
control-plane app and reuse a newly-shipped package.

Rebrand:
- npm scope @cirrus/* → @lunora/* across deps + imports.
- app functions dir cirrus/ → lunora/ (+ tsconfig/eslint globs, _generated
  paths, the committed schema snapshot → .lunora-schema.json).
- reserved paths /_cirrus/* → /_lunora/* (incl. the new scheduled/queue tick
  endpoints) and the runtime-injected env.__lunoraCtx; renamed exported symbols
  (LunoraError, LunoraClient/Provider, useLunora, lunoraD1Adapter, LunoraAuth*,
  LUNORA_CRONS/FUNCTIONS); vite plugin cirrus() → lunora(); CLI config dir
  ~/.cirrus → ~/.lunora.
- wire the new required GlobalIntrospector.facetColumn via @lunora/d1's
  facetGlobalColumn.

Reuse:
- src/metering/analytics.ts is now a thin domain layer over @lunora/analytics
  (createAnalytics writeDataPoint + createAnalyticsSqlClient AE-SQL reader)
  instead of a hand-rolled writeDataPoint + HTTP SQL client.

Verified the rest is genuinely cloud-specific (cron-expression matching, AES-GCM
secret crypto, the Cloudflare REST provisioner, the per-cell CF-API token
bucket) — no upstream equivalent to fold into.

103→108 cloud tests green; runtime 379 tests green; tsc/eslint/build clean.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): rebrand the product Cirrus Cloud → Lunora Cloud

Complete the lunora rebrand to the product layer (the framework already moved):

- brand prose Cirrus Cloud → Lunora Cloud across code comments, README,
  EXTRACT, the studio (index.html title, Login/dashboard), and CLOUD-PLAN.md.
- env vars CIRRUS_* → LUNORA_*: LUNORA_ADMIN_TOKEN and LUNORA_MAIL_CAPTURE are
  functional (read by @lunora/mail); LUNORA_APP_DOMAIN / LUNORA_CELL and the
  VITE_LUNORA_URL client var follow for consistency.
- the LUNORA_CLOUD_PLANS entitlements constant.
- infra names cirrus-* → lunora-*: worker names (lunora-cloud, lunora-dispatcher),
  dispatch namespace (lunora-production), shared queue (lunora-tenant-queue),
  AE dataset (lunora_tenant_usage), the lunora.app apex, and the deploy
  dispatch-namespace prefix.
- the hosted-CLI verbs (lunora login/link/deploy) and config dir ~/.lunora.
- docs' reserved-path/marker refs (/_lunora/*, __lunora_admin__, env.__lunoraCtx).

108 cloud tests green; tsc/eslint/build clean; zero residual `cirrus` references.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* refactor(cloud): migrate functions to fluent builder api

Adapt the cloud control-plane functions to alpha v1.0.0-alpha.1's fluent
function builders: kind.input({...}).<terminal>(({ ctx, args }) => ...)
replaces the removed object form kind({ args, handler }). Regenerate
_generated/* and pick up codegen's observability block in wrangler.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): license under polyform noncommercial

The control plane is the proprietary product layer, so it must not carry
the framework's FSL-1.1-Apache-2.0 (which grants broad commercial rights).
Apply PolyForm Noncommercial 1.0.0: any noncommercial purpose is permitted,
but commercial use requires a separate license. Replaces UNLICENSED.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* chore(cloud): adopt @lunora/bindings/analytics after package fold-in

The latest alpha folded @lunora/analytics into @lunora/bindings (subpath
export ./analytics, identical API) and codegen now emits
_generated/functions.ts importing @lunora/values directly. Swap the
dependency and import specifiers, declare @lunora/values, regenerate
_generated/*, and reconcile the lockfile.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): address pr review findings

- secrets: assert the project belongs to the org in store/list/
  listEncrypted and scope queries by organizationId, closing the
  cross-org IDOR where a member of one org could read or overwrite
  another org's project secrets (+ idor tests)
- deploy: require a base64 worker bundle in POST /v1/deploy and thread
  it client → CLI → provisioner instead of uploading an empty module;
  400 on missing/malformed bundle
- studio: replace try/finally + throw-in-try with promise combinators
  in Login/Invitations/Secrets forms so React Compiler can memoize them

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): add consolidated gap analysis and build plan

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): blue/green releases with health gating and rollback

Every deployment now uploads an immutable versioned script
({alias}-v{n}); the project's stable URL follows an active-deployment
pointer that only swaps after the new script passes a health probe, so
a bad deploy never replaces a serving one (gaps.md a1). Adds
POST /v1/deployments/rollback + lunora rollback (pointer swap back to a
retained superseded release), GET /v1/tenants/route + a cached alias
resolver in the dispatcher, per-phase deployment timestamps (a2), and
an x-lunora-id debug header (b3).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): aggregate spend caps with org suspension

Per-invocation limits cap one request; nothing capped aggregate period
spend (gaps.md c1). Adds a pure spend evaluator at the wfp cost basis
with per-plan default caps (org-overridable; explicit 0 = uncapped), an
hourly enforcement cron that suspends breaching orgs and self-heals
recovered ones, and dispatcher enforcement — a suspended org's tenants
serve 503 via the sentinel plan carried through the existing cache.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): custom domains — model, txt verification, hostname routing

First slice of gaps.md b1: the domains table (unique hostname, per-org
project scoping, redirect-only rows, cloudflare custom-hostname id),
add/list/remove/markVerified functions with the same authz gates as
secrets, a pure dns-over-https verification core (_lunora txt token +
platform cname check, injectable resolver), and routeForHostname — the
dispatcher-facing lookup that only ever routes verified domains to the
project's active script. Edge routes + dispatcher wiring land next.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): wire custom domains through edge and dispatcher

Completes the code-tractable half of gaps.md b1: POST /v1/domains (add,
returns the txt record to create), POST /v1/domains/verify (dns-over-
https txt + cname checks under the caller's session, outcome recorded
via markVerified), GET /v1/tenants/custom-domain for the dispatcher,
and a cached custom-domain resolver in the dispatcher that routes
verified hostnames to the project's active script and answers
redirect-only rows directly. cloudflare-for-saas cert provisioning
remains the 🌐 half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* style(cloud): hoist the trailing-dot regex to module scope

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): server-side builds, build logs, and push-to-deploy webhook

gaps.md a3/a4: builds table with a stale-recoverable work lease and
commit-sha dedup (a repeat push reuses the successful build's bundle
hash instead of rebuilding), streamed line-per-row build logs with a
cursor-paginated tail query, github app installations linked by account
slug, push + installation webhook parsing (default-branch pushes only,
zero-sha deletes ignored) wired through the hmac-verified edge route,
and a pure build-runner orchestration (claim → fetch → execute →
complete/fail) whose tarball-fetch and container-execute ports are the
remaining 🌐 half.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): tenant log ingestion and org right-to-erasure

gaps.md b2 + d3. logs: a tenantLogs ledger fed by the tail worker via
deploy-key-gated POST /v1/logs/ingest (batch + line-length caps, lines
truncated rather than dropped), a cursor-paginated member tail query,
and a 6-hourly retention prune (48h window). erasure: owners request
org deletion (30-day reversible window); the purge cron then erases
every org-scoped control-plane row, marks deployments destroyed for
the provisioner teardown path, and removes the org.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* docs(cloud): mark shipped gaps in the build plan

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): dunning state machine and residency-aware cell placement

gaps.md c2 + f. dunning: a pure evaluator (payment failure → 14-day
grace anchored at first failure → suspend; any active/trialing
subscription rescues) driven by a 6-hourly cron over the synced
subscription states. suspensions now carry a reason so the spend-cap
and dunning crons only lift their own. placement: organizations.create
accepts a jurisdiction ("eu"/"fedramp") and picks a matching active
cell when no explicit cellId is given.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): lunora eject core — the no-lock-in exit hatch

gaps.md d2: a pure eject flow that pulls the tenant's full data
snapshot through its admin export api, scaffolds the byo wrangler.jsonc
the project would have had outside the platform (do bindings, d1
placeholder, sqlite migrations), and writes a restore readme — all over
injected ports so the packaging is fully unit-tested; the cli wires the
real i/o.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* style(cloud): use a template literal in the eject scaffold

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): studio tabs for domains, builds, and runtime logs

wires the round-7 backends into the hosted studio: a domains tab (add →
txt record callout → verify → live verified badge, remove), a builds
tab (per-project build list with live streamed output), and a logs tab
(deployment picker over a live runtime-log tail). marks c2/d2/f shipped
in the gap plan.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): fat-vs-thin runtime spike + fleet re-release pipeline

gaps.md e4 (the plan's ⭐ decide-now item). spike package
(spikes/runtime-versioning): the analysis — user functions execute
inside ShardDO and workerd has no dynamic code loading, so true-thin is
a distributed-transaction redesign, not a packaging change — plus live
probes for the three deciding hypotheses (cross-script DO bindings
under wfp, callback per-hop cost vs a 1ms viability line, fat-path
patch throughput arithmetic). provisional call: fat + pinned runtime +
automated forced re-release. that pipeline ships here too:
deployments record their runtimeVersion, and src/fleet/upgrade.ts
plans canary-first batches and halts on a dirty canary or breached
failure rate — a runtime patch becomes a paced batch job over the
existing build + health-gated release machinery.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): ring-2 pass — harden, close seams, finish product edges

security: github installations move to a staged-claim model (webhook
stages, owner/admin claims; recordPush only accepts claimed
installations and caps in-flight builds), domains.add enforces the
customDomains entitlement, and audit coverage lands for domains,
rollback, deletion requests, installation claims, and both suspension
crons. seams: build → deploy handoff via the runner's release port
(failed release keeps the artifact), stale-build self-healing cron,
superseded-release pruning (retain 3/project), and server-built pr
previews through the same pipeline. product: per-environment secrets
(all/production/preview/dev with kind-over-shared resolution + studio
picker), rollback button, suspension/deletion banners, org rename,
member role change (last-owner protected), project rename.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): switch billing to creem as merchant of record

Resolves gaps.md c3: creem (via @lunora/payment/creem) replaces the
stripe adapter as the platform's payment provider. As a merchant of
record it is the legal seller and calculates/collects/remits sales
tax/vat across 190+ jurisdictions, so the platform never inherits
worldwide tax compliance. Swaps the adapter wiring in the shard config
(CREEM_API_KEY / CREEM_WEBHOOK_SECRET / CREEM_TEST_MODE for the
sandbox), the webhook route + action to the creem-signature header,
the studio copy to creem product ids and hosted portal, and the docs.
Entitlements, dunning, plans, and quota enforcement are unchanged —
they ride the provider-agnostic subscriptions store.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): prepaid-credits overage billing core for creem

Verified against creem sdk 1.5.3: products are recurring/onetime only —
no metered subscription pricing — but creem ships a first-party credits
ledger (per-customer accounts, idempotent credit/debit by reference)
built for api metering. Overage is therefore prepaid: orgs buy credit
packs (one-time mor sales, tax handled by creem) and the platform
debits usage beyond the plan's included quota. Ships the pure core
(included quotas per plan, cost-plus overage rates, watermark-delta
debits with crash-safe idempotent references, exhausted → the existing
c1 suspension path, never negative), the overageDebits watermark table
with forward-only advancement, and 10 tests. The live credits api
wiring (CreditsLedgerPort) is the remaining 🌐 piece.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): creem credits-ledger adapter and fleet overage reconciliation

Completes the api/token-metering implementation over creem's
customerCredits api: a structural ledger adapter (balance reads via
bigint-safe strings, debits with the idempotent watermark reference,
missing account → null and never debitable), applyCreditPurchase for
the billing webhook (first purchase creates the account seeded with the
pack; later ones credit with the payment id as reference), the
organizations.creditsAccountId linkage (never overwritten once set),
and reconcileAllOverages — the fleet driver with per-org failure
isolation, watermark-advance strictly after a successful debit, and
exhausted balances handed to the c1 suspension hook.

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* feat(cloud): studio ux pass — usage meters, daily chart, command palette

Ring 3, patterns from the maple teardown (fsl-licensed observability
platform — ideas only, all code our own). usage tab: included-vs-used
plan-quota meters (amber at 80%, red past allowance, honest prepaid-
credits overage label) + a per-day request-volume chart over the new
usage.series query, rendered with a zero-dependency svg bar chart.
adds a ⌘k command palette (tab navigation + actions, substring match,
arrow/enter/escape keyboard flow, state reset by remount) wired into
the org dashboard. gaps.md gains the ranked ring-3 backlog (alerting
pillar, health charts, log-viewer upgrade, design tokens, onboarding
checklist, mcp surface, integrations hub).

https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj

* fix(cloud): keep cron triggers within cloudflare cap

The control-plane Worker declared 4 distinct cron expressions (0 */1, 0
*/6, 0 */12, */1) — one over Cloudflare's hard limit of 3 Cron Triggers
per Worker, which would reject the deploy.

The lone 0 */12 trigger existed solely for "purge deleted organizations".
Fold that job into the existing 6h bucket: the purge gates on each org's
own 30-day retention cutoff, so a tighter cadence only shortens erasure
latency — it never erases early. Codegen drops the 0 */12 trigger, leaving
exactly 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01423xZDDqzhQ5D79vy25huF

* feat(cloud): observability ingest pipeline — issues + incidents (Phase 3) (#140)

* feat(cloud): add the observability ingest pipeline (issues + incidents)

Phase 3 of the observability plan — durable, cross-deployment monitoring
in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport.

- ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant
  `otlpSink` and the container exporter, decodes the error spans
  (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents
  through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous
  — the cloud app has no queue producer binding, so ingest inserts to D1
  directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the
  plaintext admin token.
- store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with
  `@lunora/fingerprint` (the same hash the local Studio computes, so a local
  Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts`
  member-authorized read/triage functions. A `TelemetryStore` adapter
  (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a
  guarded Pipeline→R2 archive, each a no-op without its binding.
- dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the
  `logStreams` entitlement, wired into `OrganizationDashboard`.
- bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket.

Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on
the cloud branch; the graft folds away once Phase 1 lands on alpha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* feat(cloud): observability alerts — rules + delivery (Phase 4) (#141)

* feat(cloud): add observability alerts — rules, firing + delivery

Phase 4 of the observability plan (the "watches while you sleep" tier),
stacked on the Phase 3 ingest.

- schema: `alertRules` (name, target issue/incident, threshold, channel
  email/webhook, destination, enabled) + `alerts` (fired-alert audit trail
  with firing→delivered state, notification denormalized).
- firing: the telemetry `ingest` mutation loads the org's enabled rules and
  fires each the first time a source's count crosses its threshold
  (`before < threshold <= after`, so exactly once), inserting a `firing`
  alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts`
  (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`.
- delivery: the `/v1/telemetry` edge handler delivers fired alerts
  best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps
  them delivered — never blocking or failing ingest.
- functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list,
  markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered).
- dashboard: `AlertsSection` (manage rules + recent fired alerts), gated
  behind the `logStreams` entitlement, wired into `OrganizationDashboard`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

* fix(cloud): validate webhook alert destinations against SSRF

An alert rule's webhook `destination` is `fetch`ed by the control plane
when the alert fires, so an owner/admin could otherwise aim it at internal
infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) —
server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https
only, public host, no embedded credentials, no loopback/private/link-local
IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in
`deliverAlert` (never fetch an unsafe target — defense in depth for any
rule created before this guard). String-level, so it can't defeat DNS
rebinding, but it blocks the direct-address cases. Unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cloud): harden webhook SSRF guard

Two SSRF gaps in the Observability alert delivery path:

- deliverAlert followed webhook redirects, so a destination that passes
  isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the
  metadata IP). Set redirect: "manual" and reject 3xx responses.
- isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which
  the URL parser compresses to ::ffff:7f00:1) and the unspecified
  address (::) through. Reject the whole ::-prefixed non-global class.

Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already
blocked via WHATWG URL normalization; added as regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cloud): AI incident triage (@lunora/ai) — Phase 4C (#142)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(runtime): use LunoraError not undefined CirrusError in cloud endpoints

The scheduled-tick and queue-dispatch admin endpoints threw `new
CirrusError(...)`, a class that exists nowhere in the repo, so the file
failed to type-check (TS2304). The intended class is `LunoraError`, already
imported and used throughout the file.

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

* feat(cloud): full tenant log management with structured fields + trace correlation

Consume the structured, trace-correlated logs the framework now emits
(shared/log-event.ts) — the cloud log path kept only 3 severities + a flat
line and had no producer. Closes the framework side of Maple gap #2.

- Producer (GAPS.md B2, the missing piece): src/tail/worker.ts — the
  dispatch-namespace tail worker decodes each tenant `{source:"lunora",
  type:"log"}` console event (src/tail/parse.ts, pure + unit-tested), groups
  them per script, and POSTs batches to POST /v1/logs/tail. Holds one platform
  secret (LUNORA_TAIL_SECRET), not per-org deploy keys; the route resolves
  scriptName → org (logs.orgForScript) and stores via logs.ingestInternal.
  Deployed from tail.wrangler.jsonc.
- Store: tenantLogs widened to the full LogEvent shape — 7-tier severity,
  message, structured fields, functionPath, traceId/spanId, userId, shardKey —
  plus (scriptName, createdAt) and (org, traceId) indexes.
- Query: logs.list gained server-side levels/functionPath/traceId/search
  filters + a cursor and bounded limit, newest-first.
- UI: the studio Logs tab renders severity chips (filter), search, structured
  fields, and a short trace id per line.

Still 🌐: the provisioner setting tail_consumers on tenant scripts, an e2e run,
and correlating error/fatal lines to OTLP Issues by traceId (follow-up).

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

* fix(cloud): provision tenant bindings so deployed workers boot

The deploy handler built the provisioner spec with an empty binding set
(`bindings: {}`), so every uploaded tenant Worker was created with no
Durable Object binding and no `new_sqlite_classes` migration tag. A real
Lunora app always exports ShardDO, so it could never boot — the deploy
pipeline could only ship a binding-less worker.

The deploy request now carries the app's binding manifest (DO classes,
optional per-tenant D1/R2) which the CLI reads from `wrangler.jsonc`, and
the handler normalizes it to a spec that always includes the ShardDO
floor even when a caller under-declares or omits it. Malformed entries
are dropped and the DO list is capped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): tear down Cloudflare scripts for destroyed deployments

The lifecycle crons (cleanupExpiredPreviews, pruneSuperseded,
organizations.purgeDeleted) only transitioned a deployment to `destroyed`
— nothing ever deleted the Cloudflare dispatch script, so dispatch
namespaces grew unboundedly (the leak GAPS.md Ring-2 flagged as closed).

Add a `teardownAt` marker and a pure, port-injected `runTeardownSweep`
(per-target failure isolation, crash-safe idempotent off the marker),
wired into the control-plane Worker's scheduled() handler on the
hourly/6-hourly buckets — right after the crons that mark rows destroyed.
No-ops without Cloudflare credentials.

Per-tenant D1/R2 teardown-by-id still needs resource-id persistence and
is left as a follow-up; script deletion is the load-bearing fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): fold Analytics-Engine usage into the metering ledger

The dispatcher wrote one AE data point per tenant request, but nothing
ever read them back — createHttpAnalyticsReader had no caller, so
`platformUsage` only held what tenants self-report over POST /v1/usage
(nothing, in practice). Spend caps, the usage summary, and the usage
chart therefore evaluated an empty ledger.

Add a per-cell `usageReadAtMs` checkpoint and a pure, port-injected
`runUsageRollback` that delta-reads AE (`timestamp > checkpoint`),
attributes each dispatch script to its org/deployment, and appends
`requests` rows — then advances the checkpoint so re-runs never double
count. A per-row ledger failure is dropped rather than retried (under-
count, never double-bill — the same fail-safe as usage.rollup). Wired
into scheduled() on the hourly/6-hourly buckets; no-ops without
Cloudflare credentials.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): build-queue dispatcher (claim → run → drain)

`builds.claimNext` had no caller: enqueued builds sat untouched until the
24h expiry cron failed them with "no build runner picked this up". Add
the missing claim→run loop as a pure, port-injected `runBuildDispatch`
(bounded per-tick drain; a failed build never aborts the drain), fully
unit-tested against the runner ports.

Production activation stays gated on the runner's 🌐 seams — `execute`
(a throwaway Cloudflare Container running `lunora build`) and
`fetchSource` (GitHub App tarball) — which need live container infra, so
the dispatcher is not yet wired into scheduled(): claiming builds with no
executor would only burn them. This lands the verified logic so the
remaining work is purely the container seam, not the orchestration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* docs(cloud): split GAPS legend into wired vs pure-module (🧩)

The single ✅ conflated "tested pure function exists" with "feature runs".
Add a 🧩 marker for tested-but-uncalled modules, a dated wiring-pass
section covering the four gaps just addressed, and correct the two most
misleading inline entries:

- A3 builds: the claim dispatcher now exists (was missing); only the
  container execute() seam remains 🌐.
- C3 overage credits: reconcileAllOverages / applyCreditPurchase have no
  production caller (verified) — scheduling + webhook mapping are code
  (🔨), not credentials (🌐), so the honest status is 🧩, not "✅ core".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): boot-time route classification scanner

Port of Openship's route-scanner idea (Apache-2.0) to the /v1 router. The
control-plane routes each did inline auth then delegated to a self-
authorizing function, but nothing forced a *new* route to be classified —
an unclassified endpoint could ship silently and read as protected.

Every route now carries an explicit RouteSpec.auth (deployKey / session /
webhookHmac / tailSecret / adminToken / public), and
assertRoutesClassified runs when createDeployRouter builds the table: a
missing/unknown classification, a public route with no reason, or a
duplicate (method, path) throws at construction — the Worker fails to
start rather than serving an unclassified route. The flat dispatch tables
are derived from the one checked list (GET + POST unified). The spec's
opt-in `mcp` field is the allowlist the MCP surface will read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): MCP surface generated from the route registry

Port of Openship's "MCP tools derived from the route registry" idea
(Apache-2.0). A `/v1/mcp` JSON-RPC endpoint (tools/list + tools/call)
exposes only routes that opt in via RouteSpec.mcp, and every tool call
dispatches back through the real router carrying the agent's own bearer
credential — so it runs the identical auth + rate-limit + handler +
function-authz path as any HTTP caller; the MCP layer grants no privilege.

A hard deny-list (buildMcpTools) guarantees token/secret/tenant-access
routes (/v1/secrets, /v1/admin, /v1/invitations/send, /v1/logs/tail) and
the surface itself (/v1/mcp) can never become tools even if mis-annotated
— the same scope-escape guard Openship applies to tokens/auth/mcp. Only
bearer-callable (deployKey/adminToken) opted-in routes are eligible;
session/webhook routes are excluded. deployments.rollback is the first
tool exposed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* chore(codegen): regenerate _generated for teardownAt + usageReadAtMs

Keeps the emitted dataModel/shard/drizzle types consistent with the new
deployments.teardownAt and cells.usageReadAtMs schema columns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): tear down tenant D1 + R2, and test the sweep glue

Extends resource teardown past the dispatch script (#2). The lifecycle
sweep now also deletes the per-tenant D1 database and R2 bucket, resolved
by the same naming convention the provisioner creates them under (shared
tenantD1Name / tenantR2Bucket helpers — no drift, no new persistence).
New CF API methods: findD1DatabaseByName + deleteD1Database (uuid) and
deleteR2Bucket (name). Script + D1 delete are retryable; R2 is best-effort
(a non-empty bucket needs an S3-API object purge the teardown context
lacks — logged, left for follow-up). D1 (every .global() app has one) and
empty R2 buckets are now fully reclaimed.

Also extracts the scheduled() sweep glue into testable port-builders
(#4): teardownPorts + usageRollbackPorts over a structural ControlPlaneDb,
so the row→target mapping, the teardownAt stamp, the ledger insert, and
the per-cell checkpoint are unit-tested against a fake store instead of
living untested inside server.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): carry cronSpecs + bindings from wrangler on deploy

The cron fan-out read live deployments' cronSpecs, but nothing ever
populated them: deployments.create accepted the field yet the deploy
handler/router never passed it, so readCronTargets always returned [] and
the entire §2.4 tenant-cron fan-out had no data source (#3).

Add parseWranglerManifest — a pure reader that extracts the binding
manifest (DO classes / D1 / R2) and cron expressions from a tenant's
wrangler.jsonc — and thread cronSpecs through the deploy request →
handler → create mutation. The deploy client + CLI now forward both
bindings and cronSpecs, so a real deploy provisions what the Worker needs
and registers the crons the fan-out drives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

* feat(cloud): synthetic uptime monitoring with alerting

Adds external-vantage uptime to the control plane's Observability tier — the
piece a deployment can't self-report (if it's down, it can't say so).

- Probe: pure probeDeployment (generalizes the deploy-time healthCheck — GET,
  sub-500 = up, latency + timeout, never throws), a consecutive-failure state
  machine, and a summarizer, all unit-tested (src/uptime/probe.ts).
- Sweep: runUptimeSweep over injected ControlPlaneDb ports (mirroring the
  teardown/usage sweeps) probes every live deployment, records a uptimeChecks
  row, advances uptimeState, and fires an "uptime" alert the first time a
  deployment's failures cross a rule threshold — reusing crossesThreshold,
  renderAlert, and the alerts table/delivery pipeline (src/uptime/sweep.ts).
- Edge: server.ts scheduled() runs the sweep on the every-minute tick and
  delivers fired alerts over their channel (webhook/email), stamping the outcome.
- Alerts gain an "uptime" target (schema + createRule + renderAlert), so users
  configure "page me when my deployment is down" alongside issue/incident rules.
- Read side: lunora/uptime.ts (summary + recent queries, retention prune cron)
  backs a new Uptime dashboard section.

Cron triggers stay at 3 expressions (prune rides the 6h bucket, the probe rides
the existing every-minute tick). Full suite: 272 tests, lint:types clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019M6G6CAoLVrQMxDYg7BWq2

* fix(cloud): address thermo review of uptime monitoring

Security/correctness (branch audit):
- SSRF: the sweep fetched a …
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.

1 participant