Implementation Plan · grounded revision

ForgeGraph × Effect × Alchemy
Semantic Control Plane Draft

2026-09-09 repo: ForgeGraph @ main (af27aff6) also touches: create-gmacko-app author: Claude (from the 09-09 markdown draft)

ForgeGraph becomes the join point for four graphs: what Effect contracts declare, what ForgeGraph means (ownership, SLA, visibility, environments, migration policy), what Alchemy / Cloudflare has deployed, and what OTel observes. This revision keeps the original thesis and design rules intact but re-anchors every phase to files that exist today, and records where the draft's premises do not yet hold.

9Phases
50Tasks
2Repos
3Decisions needed
~3 wkPhase 1 + 2

Overview

The source draft assumed ForgeGraph already runs on Effect and already provisions through Alchemy. Neither is true. Effect (4.0.0-rc.112, effect/unstable/httpapi) lives in the create-gmacko-app template that the fleet's Worker apps are generated from; ForgeGraph's own API is tRPC + Zod. Alchemy is adopted nowhere in the fleet: apps deploy with wrangler through ForgeGraph's cloudflare-workers target, and ForgeGraph already owns a small declared→deployed loop for D1/R2/KV.

So the plan splits cleanly into two halves. Phases 1–2 and 5 need no Alchemy and deliver the architectural claim that matters most: the Effect contract is executable architecture metadata, ingested and displayed by ForgeGraph without anyone repeating themselves. Phases 3, 4, 6, 7, 8 hinge on one decision, adopting Alchemy in the template, and are staged so that a Cloudflare-API reader delivers the "deployed" graph for the whole fleet first, with Alchemy state as a second, richer source once it lands.

What exists today (verified 2026-09-09)

✔ Effect HttpApi contract in the templatecreate-gmacko-app/packages/domain/src/api.ts — AppApi with groups auth/posts/settings/admin/health; credentials are middleware tags (Session, SessionOrKey(scope)) in security.ts.
✔ HttpApi.reflect already usedpackages/domain/src/inspect.ts — emits one row per endpoint (route, credential, roles, statuses). The API_AUTH matrix and contract tests derive from it. This is the seed of compileHttpApi.
✔ Annotation pattern already in usepackages/domain/src/middleware.ts — RateLimitScopeAnnotation is a Context.Service read from endpoint annotations. Context.Reference(key, {defaultValue}) is available in rc.112 for the private-by-default vocabulary.
✔ Spans are already named group.endpointpackages/api/src/boundary.ts:127 — EndpointBoundaryLive names the server span `${group.identifier}.${endpoint.identifier}`. Prefixing the service id gives the canonical operation id for free.
✔ Contract export pipelinesdks/openapi/generate-spec.ts — OpenApi.fromApi(AppApi) with a snapshot test and drift check. The ForgeGraph IR export slots in beside it.
✔ ForgeGraph declared→deployed loop for CF resourcespackages/db/src/schema/app-resource.ts (declared, externalId null until reconciled) → apps/web/src/lib/cf-reconcile.ts (creates) → apps/web/src/app/api/agent/bindings/route.ts (agent synthesizes wrangler bindings, agent/cmd/agent/cloudflare_worker_vars.go:268).
✔ PR previewspackages/db/src/schema/pr-preview.ts — a preview is a deployment_targets row on the beta stage, worker <slug>-pr-<n>, stable hostname, shares beta's DB; PRs touching migrations are refused (blocked_migration, packages/api/src/lib/preview.ts:38).
✔ Attestations + evidencepackages/db/src/schema/attestation.ts — per-changeset typed gates with evidenceRef/evidenceSha, waivers, merge gating. A contract snapshot is one more attestation type.
◐ Migration hashing, no ledgeragent/internal/cmdexec/run_migration.go:94 — SHA-256 of each SQL file on dispatch, printed and forgotten. No manifest table, no destructive-SQL lint. Template migrations are flat wrangler D1 files under packages/db/migrations, expand/contract enforced by no-d1-table-rebuild.
◐ Stage naming is splitcanonical stage_name = development | staging | beta | production (stage.ts:5); legacy environment = staging | prod_canary | prod_main is still used by app_environments and environment_states. New Environment tables must key on stages + pr_previews, never the legacy enum.
◐ OTel attributes, no edgespackages/otel/src/tracer.ts:64 sets service.name, deployment.environment, fg.lane, service.version. service_metrics is per (app, stage, spanName) aggregate; nothing derives caller→callee. Traces go to the external collector, not ForgeGraph's DB.
✘ No Effect in ForgeGraphzero effect deps in the monorepo. The server validates with Zod. Keep it that way: the IR crosses the boundary as JSON, validated by a Zod mirror.
✘ No Alchemy anywhereonly a checkout under t3code/.repos/alchemy-effect (alchemy 2.0.0-beta.65, Effect-native). It has Stage (a string service), ResourceState {resourceType, fqn, logicalId, instanceId, bindings, downstream, attr}, D1 CloneDatabase (export→import), and an HTTP state-store API (/state/stacks/:stack/stages/:stage/resources/:fqn).
✘ No manual exposes to retire.forgegraph.yaml (agent/cmd/fg/commands/config.go, Zod mirror in api/fg/config/apply/route.ts:179) has app/db/stages/routes/resources/domains/health/traffic/jobs. No endpoint, SLA, owner or visibility fields. The draft's "deprecate old exposes" acceptance collapses to "no one is ever asked to write one".
✘ No API/contract UIapps/[id]/endpoints/[endpointId] renders health probe history. App tabs (app-detail-tab-items.ts) have no API tab. Name the new surface operations to avoid the collision.

Architecture

OBSERVED

DEPLOYED

FORGEGRAPH semantic graph

DECLARED

POST /api/fg/contracts

join on operation id

join on app + stage

join on stage / pr

Effect HttpApi
packages/domain (template)

compileHttpApi
@forgegraph/contract

contract_operations

environments /
environment_revisions

migration_manifests

Cloudflare API reader
wrangler-deployed Workers

Alchemy state store
hosted by ForgeGraph

deployed_resources /
deployed_bindings

OTel spans
fg.operation.id

observed_edges

The four graphs and the three joins ForgeGraph owns. Solid = data flow, dashed = join keys.
Diagram source (mermaid)
flowchart LR
  subgraph declared["DECLARED"]
    E["Effect HttpApi<br/>packages/domain (template)"]
    C["compileHttpApi<br/>@forgegraph/contract"]
    E --> C
  end
  subgraph fg["FORGEGRAPH semantic graph"]
    OP[("contract_operations")]
    ENV[("environments /<br/>environment_revisions")]
    MIG[("migration_manifests")]
  end
  subgraph deployed["DEPLOYED"]
    CF["Cloudflare API reader<br/>wrangler-deployed Workers"]
    AL["Alchemy state store<br/>hosted by ForgeGraph"]
    DR[("deployed_resources /<br/>deployed_bindings")]
    CF --> DR
    AL --> DR
  end
  subgraph observed["OBSERVED"]
    OT["OTel spans<br/>fg.operation.id"]
    OE[("observed_edges")]
    OT --> OE
  end
  C -- "POST /api/fg/contracts" --> OP
  OP -. "join on operation id" .-> OE
  OP -. "join on app + stage" .-> DR
  DR -. "join on stage / pr" .-> ENV
  MIG --> ENV

Where each piece lives

PieceRepo / pathWhy there
@forgegraph/contract — annotations, SLA resolver, compileHttpApi, IR types, serializerForgeGraph packages/contract, published to the private registry like @forgegraph/otelShared by every Effect app; versioned with the IR. effect is a peer dependency pinned to the template catalog; the ./ir entry has no Effect import so the server can use it.
Contract emission + annotations on the real APIcreate-gmacko-app packages/domain, sdks/contractThe template is the "one real service converted". Every generated app inherits it.
Ingest, storage, gates, UIForgeGraph apps/web/src/app/api/fg/contracts, packages/db, packages/api/src/routers/contract.ts, apps/web/src/app/apps/[id]/operations-tab.tsxZod-validated JSON at the boundary; no Effect in the hub.
Deployed-graph importerForgeGraph packages/api/src/lib/deployed-graph/ (+ agent handler for Alchemy state)Two readers, one output shape.
Migration manifest + lintForgeGraph agent/internal/migrationlint (Go) exposed via fg migration check and fg-checkRuns in every repo's CI without a Node dependency.

Goals & Non-goals

Goals

  • Zero hand-authored endpoint, schema, error or auth catalogs for Effect apps. HttpApi.reflect is the only source.
  • Stable semantic operation ids <serviceId>.<group>.<endpoint> that survive route changes and match existing span names.
  • Leaf-merged hierarchical SLA with per-leaf provenance, visible in the UI.
  • Private-by-default visibility, with authentication derived from declared security middleware and only overridable by annotation.
  • A declared vs deployed vs observed join on a single operation edge.
  • Environment = complete graph resolution; PR previews with their own D1 clone and draft migrations that cannot poison each other.
  • Migration governance with immutable approved/released hashes and a destructive-SQL gate.

Non-goals

  • Adding Effect to ForgeGraph's hub. The IR is JSON; the hub validates with Zod.
  • Another Cloudflare reconciler, state store, or D1 migration runner. Alchemy owns those once adopted; until then wrangler + cf-reconcile.ts stay as-is.
  • A parallel YAML DSL. .forgegraph.yaml gains nothing endpoint-shaped.
  • D1 copy-on-write branching. DatabaseMaterializer is a boundary so a native fork can replace export/import later.
  • Dashboard-only metadata. Every panel reads the canonical tables.
  • Retrofitting non-Effect apps (tRPC/Next.js, ForgeGraph itself). An OpenAPI import adapter is a later option, not a phase.

Design rules carried over unchanged

These are the draft's section 1, kept verbatim in spirit. They are restated here so the phases can cite them.

Milestones

Phase 1 — Annotations + automatic HttpApi contract compiler Shipped to review

Deliver @forgegraph/contract and wire it into the template. Everything is unit-testable; nothing touches the hub's database.

App detail: API tabForgeGraph serverForgejo CI@forgegraph/contractpackages/domain (template)App detail: API tabForgeGraph serverForgejo CI@forgegraph/contractpackages/domain (template)compileHttpApi(AppApi, { serviceId })HttpApi.reflect + leaf-merged SLA + provenancecontract.json (IR v1, fingerprint)POST /api/fg/contracts (bearer, commit sha)validate IR, unique operation ids, upsert snapshot + operationsattestation api-contract = satisfiedtrpc contract.forApp(appId)operations, SLA value + provenance, auth, public
Phase 1 stops at contract.json. Phase 2 adds the POST, storage and tab.
Diagram source (mermaid)
sequenceDiagram
  participant D as packages/domain (template)
  participant K as @forgegraph/contract
  participant CI as Forgejo CI
  participant S as ForgeGraph server
  participant UI as App detail: API tab
  D->>K: compileHttpApi(AppApi, { serviceId })
  K->>K: HttpApi.reflect + leaf-merged SLA + provenance
  K-->>CI: contract.json (IR v1, fingerprint)
  CI->>S: POST /api/fg/contracts (bearer, commit sha)
  S->>S: validate IR, unique operation ids, upsert snapshot + operations
  S->>S: attestation api-contract = satisfied
  UI->>S: trpc contract.forApp(appId)
  S-->>UI: operations, SLA value + provenance, auth, public
TaskFilesVerificationStatus
1.1 Scaffold packages/contract: entries ./effect (needs effect peer) and ./ir (plain TS + Zod, no Effect). Mirror packages/otel packaging (pnpm publish, publishConfig.exports).packages/contract/package.json, tsup.config.ts, vitest.config.tspnpm -F @forgegraph/contract build; importing ./ir from a file with no effect installed type-checksDone
1.2 Annotation vocabulary as Context.Reference with defaults: Sla (patch, default {}), IsPublic (false), Authentication (service). Keys @forgegraph/contract/<Name>. Export annotate(...) helpers.packages/contract/src/effect/annotations.tsUnit: absent annotation returns default; Context.get(mergedAnnotations, IsPublic) on an unannotated endpoint is falseDone
1.3 SLA leaf resolver + provenance over the ten leaf paths (availability, errorRate, latency.p50/p95/p99/maxMs, timeoutMs, maxRetries, qps, concurrency). Levels global → service → api → group → endpoint → environment.packages/contract/src/sla.ts (shared, no Effect)Unit: endpoint overriding only latency.p99Ms keeps p95Ms from global; provenance map names the exact level per leaf; invalid values (availability > 1, negative ms) rejectedDone
1.4 compileHttpApi(api, { serviceId, globalSla?, serviceSla? }) via HttpApi.reflect: per endpoint collect method, path, params/query/headers/payload schemas, successes/errors by status, middleware identifiers, mergedAnnotations. Read Sla patches independently at api/group/endpoint (Context.getOrUndefined(x.annotations, Sla)) for partial merge.packages/contract/src/effect/http-api.tsUnit against a fixture HttpApi with 2 groups × 3 endpoints; snapshot of the IRDone
1.5 Derive authentication from security middleware by default (no security → anonymous; a single cookie/session scheme → user; bearer key only → service; both → mixed). An explicit Authentication annotation overrides and records a declaredVsDerived mismatch flag in the IR. This is rule R1 applied to auth.packages/contract/src/effect/authentication.tsUnit: template-shaped Session → user, SessionOrKey → mixed, none → anonymous; override sets the mismatch flagDone
1.6 Operation IR v1 (./ir): OperationContract with transport: { type: "http" | "rpc", ... }, policy: { isPublic, authentication, sla: { policy, provenance } }, middleware[], request/successes/errors schema refs. Zod schema + JSON Schema of the IR itself, with irVersion: 1.packages/contract/src/ir/index.ts, ir/schema.tsUnit: Zod parses the fixture IR; unique-id check fails on a duplicate operation idDone
1.7 Serializer boundary: Effect Schema.Top → { id (identifier annotation when present), normalized JSON Schema via Effect's public exporter, fingerprint = sha256 of canonical JSON, title }. Contract-level fingerprint over sorted operations. No private AST in the wire format.packages/contract/src/effect/serialize.tsUnit: same schema twice → same fingerprint; reordering endpoints does not change the contract fingerprint; renaming a field doesDone
1.8 Template: annotate @gmacko/domain. Health group IsPublic: true + anonymous; auth.me public + user; admin group stays private. Add a service-level SLA patch on AppApi.create-gmacko-app/packages/domain/src/{api,health/api,auth/api}.tsExisting domain tests still pass; inspectApi output unchangedDone
1.9 Template: sdks/contract/generate-contract.ts writes contract.json next to the OpenAPI document; snapshot test in packages/domain/src/__tests__/contract.test.ts; drift check in the SDK workflow.create-gmacko-app/sdks/contract/*, domain testspnpm -F @gmacko/contract-spec generate; snapshot pinned; CI drift check red when an endpoint changes without a snapshot updateDone
1.10 Fold inspect.ts onto the compiler: EndpointInfo becomes a projection of the IR so the API_AUTH matrix and contract tests keep one source.create-gmacko-app/packages/domain/src/inspect.tsAPI_AUTH matrix regenerates byte-identicalDone (agreement test; fold deferred)
Implementation notes: reflect signature, annotation reads, RPC seam

HttpApi.reflect(self, { predicate?, onGroup({ group, mergedAnnotations }), onEndpoint({ group, endpoint, mergedAnnotations, middleware: ReadonlySet<HttpApiMiddleware.AnyService>, successes: Map<status, Schema[]>, errors: Map<status, Schema[]> }) }) is the whole surface needed. api.annotations, group.annotations, endpoint.annotations are each a Context.Context<never>, so partial SLA patches are read per level without touching internals. Endpoint method and path are on endpoint.method / endpoint.path; the security scheme of a middleware is on the service via HttpApiMiddleware.isSecurity (already used by inspect.ts).

Keep OperationContract.transport a discriminated union from day one. The RPC extractor (compileRpcGroup) is a Phase 2+ follow-up behind the same IR; nothing in storage or UI may assume HTTP.

Effect 4 is a release candidate. Pin effect as a peer range equal to the template's catalog entry and add a CI job in ForgeGraph that installs the template's catalog version and runs the contract tests, so an upstream rc bump breaks here first, not in a generated app.

Phase 1 acceptance: no manual endpoint declaration anywhere; API/group/endpoint annotation inheritance works; a partial endpoint SLA override preserves higher-level leaves; unannotated endpoints are private and derived-auth; middleware, success and error schemas appear in the IR; contract.json is a deterministic build artifact of the template.

Phase 2 — Ingest operations into ForgeGraph and show them Shipped to review

Store contracts per commit, join them to what each stage is running, and render them in a new tab sourced only from the IR.

TaskFilesVerificationStatus
2.1 Tables: contract_snapshots (appId, commitSha, changesetId?, irVersion, fingerprint, ir jsonb, createdAt; unique appId+commitSha) and contract_operations (snapshotId, operationId, groupId, endpointId, transportType, method, path, isPublic, authentication, authMismatch, slaPolicy jsonb, slaProvenance jsonb, middleware jsonb, successStatuses, errorStatuses; unique snapshotId+operationId). Numbered migration following the a/b suffix convention.packages/db/src/schema/contract-snapshot.ts, contract-operation.ts, packages/db/drizzle/0102_contracts.sqlSchema-shape test like app.test.ts; db:push against a dev DBDone
2.2 POST /api/fg/contracts (bearer, workspace-scoped): body { appSlug, commitSha, changesetId?, contract }. Validate with the ./ir Zod schema, enforce unique operation ids, upsert snapshot + operations. Reject a fingerprint mismatch.apps/web/src/app/api/fg/contracts/route.tsRoute test: valid IR → 201; duplicate op id → 422; wrong workspace → 404Done
2.3 Attestation type api-contract: the POST marks it satisfied with evidenceRef = snapshot id and evidenceSha = commit. Rule engine can require it on merge. Also fail it when the contract is missing for a changeset whose repo has contract: true in .forgegraph.yaml.packages/api/src/lib/attestations/*, apply route applySchema gains an optional contract booleanExisting attestation tests + one new: PR without contract on an opted-in repo is blockedDone
2.4 CI step in the template deploy/CI workflow: after tests, pnpm -F @gmacko/contract-spec generate then fg contract publish --file sdks/contract/contract.json. New Go subcommand posts to 2.2 using the CI workspace token.agent/cmd/fg/commands/contract.go, create-gmacko-app/deploy/forgegraph/deploy.yml and the PR CI workflowGo unit test on the request shape; a real PR on a generated app shows the attestation satisfiedDone
2.5 tRPC router contract: forApp(appId) returns the latest snapshot plus, per stage, the snapshot matching the stage's active deployment commit (join deployments.commitSha). Exposes SLA value + provenance, auth, public, middleware, schemas.packages/api/src/routers/contract.ts, routers/index.tsRouter test with two snapshots and a beta deployment on the older one → beta shows the older contractDone
2.6 UI: add { key: "operations", label: "API" } tab; list grouped by group with method/path, badges for public/auth/mismatch, resolved SLA; detail page /apps/[id]/operations/[operationId] with schemas, errors, middleware, provenance per SLA leaf. Diff view between latest and each stage's deployed contract.apps/web/src/app/apps/[id]/app-detail-tab-items.ts, operations-tab.tsx, operations/[operationId]/page.tsxBehavior test (*.behavior.test.tsx) for the tab; storybook fixture for the operation rowDone
2.7 Public-mutation review gate: an operation that is isPublic + anonymous + non-GET raises a contract-policy finding on the changeset (warn now, blocking per rule engine later).packages/api/src/lib/contract-policy.tsUnit: POST /signup public+anonymous flags; GET /health does notDone
Phase 2 acceptance: changing an Effect endpoint in a generated app updates the ForgeGraph API tab on the next CI run with no other file edited; the tab is sourced only from reflection; the deployed-contract-per-stage join works off existing deployment rows.

Phase 3 — Deployed graph: Cloudflare reader first, Alchemy state second Todo

Decision A (recommended: yes, template-first). Adopt Alchemy in create-gmacko-app for the Worker + D1 + R2 + rate-limit bindings, replacing wrangler deploy in scripts/deploy-stage.mjs. ForgeGraph does not need Alchemy for Phase 3a and gets fleet-wide value without it; Alchemy unlocks 3b, 6, 7 and 8 with far less ForgeGraph code. Alchemy is 2.0.0-beta, Effect-native and matches the template's Effect major, which is the strongest argument for doing it in the template rather than in the Go agent.
TaskFilesVerificationStatus
3a.1 Tables deployed_resources (appId, stageId, prPreviewId?, source: cf-api | alchemy, kind, logicalId, physicalId, physicalName, revision, attrs jsonb, observedAt) and deployed_bindings (fromResourceId, bindingName, toResourceId | toExternal, kind).packages/db/src/schema/deployed-*.tsSchema-shape testsDeferred
3a.2 Cloudflare reader: for each cloudflare-workers target read the script's settings/bindings (D1, R2, KV, DO, queues, service bindings, rate limits), version id, routes/custom domains via the broker (workers + dns purposes). Emit rows; diff against app_resources (declared) and record drift.packages/api/src/lib/deployed-graph/cloudflare-reader.ts, uses cloudflare-broker.tsUnit with recorded API fixtures; live: one app shows its D1 binding as declared+deployedDone
3a.3 Run the reader after every successful deployment (hook in the deploy completion path) and on a slow cron; surface drift on the Resources tab and as a binding-drift alert.deploy completion handler, resources-tab.tsxDeploy a change that adds a binding → drift clears after the runDone
3b.1 Correlation table alchemy_resources (appId, stack, stage, fqn, logicalId, resourceType, instanceId, physicalId?, physicalName?, semanticId, semanticKind). Stage mapping: production → prod, beta → beta, preview → pr-<n>.packages/db/src/schema/alchemy-resource.tsSchema test; mapping function unit testDone
3b.2 ForgeGraph hosts Alchemy's HTTP state store: implement the ten /state/* routes (list stacks/stages/resources, get/put/delete resource by fqn, replaced-resources, stack output, delete stack, /version) as Next route handlers with bearer auth scoped to the app. Persist ResourceState JSON verbatim in alchemy_state; project the correlation rows on every PUT.apps/web/src/app/api/fg/alchemy/state/**Contract test against alchemy's HttpStateStore client pointed at a local hub; alchemy deploy of the template round-tripsDone
3b.3 Template: alchemy.run.ts declaring Worker, D1, R2, rate limits with logical ids equal to the .forgegraph.yaml resource names; state store URL and token injected by ForgeGraph at deploy. Semantic registration: the Worker's serviceId = app slug, so operations and resources join by construction.create-gmacko-app/alchemy.run.ts, scripts/deploy-stage.mjs, .forgegraph.yamlStaging deploy via ForgeGraph succeeds; 3b.1 rows populated; Resources tab shows source = alchemyTodo
3b.4 Alchemy-state importer emitting the same deployed_* rows as 3a.2 (bindings from ResourceState.bindings, dependencies from downstream).packages/api/src/lib/deployed-graph/alchemy-reader.tsBoth readers produce identical rows for the same deployed appTodo
Phase 3 acceptance: the dashboard compares declared vs deployed for one environment; binding drift is visible; for Alchemy apps the correlation is (stack, stage, fqn) and ForgeGraph never scrapes.

Phase 4 — Environment IR and resolver Todo

Environment identity owns the hostname; revisions own code, data generation, migration base and inherited resource revisions. Built over stages and pr_previews; the legacy environment enum is not touched.

TaskFilesVerificationStatus
4.1 Tables environments (appId, kind: production | beta | preview | development, stageId, prPreviewId?, hostname, status: READY | STALE_BASE | BUILDING | FAILED | DESTROYED) and environment_revisions (environmentId, commitSha, dataGenerationId?, migrationBaseHash, configHash, inheritedFrom revisionId?, validation jsonb, promotedAt?).packages/db/src/schema/environment.ts (new, distinct from environment-state.ts)Schema tests; backfill script creates one environment per existing stage and previewTodo
4.2 Isolation strategy vocabulary: own | inherit(env) | clone(env) | shared-readonly(env) | prefix(env, prefix) | sandbox(target), declared per resource kind in .forgegraph.yaml under previews.isolation, with defaults (Worker own, D1 clone beta, R2 prefix beta, KV shared-readonly beta, auth Worker inherit beta, email sandbox).apply route Zod + config.go, packages/api/src/lib/environment/isolation.tsUnit: resolver returns a complete graph for a preview with the example mixTodo
4.3 Resolver: given an environment and the declared graph, produce the full resource resolution (every node bound to own / inherited / cloned physical identity) and the Alchemy stage plan for it.packages/api/src/lib/environment/resolve.tsGolden test: beta → pr-481 resolution matches the draft's example tableTodo
4.4 Staleness: when beta's revision advances, mark dependent previews STALE_BASE with the per-axis reasons (code, data generation age, approved migration base, config, inherited resource revision). No automatic destroy.packages/api/src/lib/environment/staleness.tsUnit: beta promote → preview flips; rebuild clearsTodo
4.5 UI: Environments page topology sourced from 4.1 (lineage edges, data generation, migration base/delta, worker revision, DNS, health, stale flags) and a beta → PR resource diff.apps/web/src/app/environments/**Behavior test; manual: preview shows "clone beta + N draft migrations"Todo

Phase 5 — Migration governance Todo

Independent of Alchemy. Works on the template's flat wrangler D1 migration files and ForgeGraph's own drizzle files alike.

TaskFilesVerificationStatus
5.1 Manifest: migrations/manifest.json = [{ id, order, hash, state }] + manifest hash, generated by the template's flatten script and by fg migration manifest for any dir.agent/internal/migrationlint/manifest.go, create-gmacko-app/packages/db/scripts/flatten-migrations.mjsGo + node tests: deterministic output; hash changes when SQL changesTodo
5.2 Governance state in ForgeGraph: migration_manifests (appId, commitSha, manifest jsonb) and migration_states (appId, migrationId, hash, state: draft | approved | released, changed by, at). Approval = merge to main; release = production deploy applies it.packages/db/src/schema/migration-*.ts, hooks in merge + deploy completionIntegration test through the deploy completion pathTodo
5.3 Planner: compare a branch manifest with the stored states and return noop | apply | rebuild-preview | reject per the draft's table (applied draft changed → rebuild; approved/released changed or removed → reject).agent/internal/migrationlint/plan.go, exposed as fg migration check and inside fg-checkTable-driven Go tests for all seven rowsTodo
5.4 Destructive-SQL lint: DROP TABLE, DROP COLUMN, RENAME COLUMN, NOT NULL tightening, type changes, plus the D1 __new_ rebuild pattern. Require a -- forge: contract header or a waiver.agent/internal/migrationlint/destructive.goFixture SQL corpus; both SQLite and Postgres dialectsTodo
5.5 Replace the blanket preview migration_guard with the planner: drafts are allowed when the preview owns its DB (Phase 7); until then keep blocking but with the planner's reason.packages/api/src/lib/preview.tsExisting preview tests updatedTodo
Phase 5 acceptance: editing an applied draft rebuilds the preview; editing an approved or released migration fails CI; beta never receives a draft.

Phase 6 — Sanitized DataGeneration Todo

Decision B. The materializer needs a D1 clone. With Alchemy adopted, use Cloudflare.D1.CloneDatabase (export → import). Without it, the same two Cloudflare calls can be issued from the Go agent through the broker's d1 purpose. The DatabaseMaterializer boundary hides which one runs; a native D1 fork replaces both later.
TaskFilesVerificationStatus
6.1 data_generations table with the draft's lineage fields (source environment/revision/database/bookmark, capturedAt, sanitizer id/version/hash, verification status + checks).packages/db/src/schema/data-generation.tsSchema testTodo
6.2 DatabaseMaterializer service boundary with the clone implementation; runs as an agent job dispatched over the hub.packages/api/src/lib/data-generation/materializer.ts, agent handlerLive: clone a small D1 and row-count both sidesTodo
6.3 Sanitizer runner: versioned, deterministic rules (emails → *.invalid, synthetic names, tokens/keys/secrets null, webhook targets disabled, payment ids test, tenant cardinality preserved). Rules live in the app repo under packages/db/sanitize/; hash recorded.template packages/db/sanitize/*.sql, runner in the agentRun twice → identical output hashTodo
6.4 Fail-closed verification: real email domains, secret/token regexes, live webhook URLs, production payment credentials, external integration destinations. Any hit fails the generation.agent/internal/sanitize/verify.goFixture DB with planted PII fails; clean DB passesTodo
6.5 Beta refresh flow: prod → clone → sanitize → verify → approved migrations → CI/smoke → promote candidate (Phase 8 owns the last step).orchestration in packages/api/src/lib/data-generation/refresh.tsEnd to end on the template app's betaTodo

Phase 7 — PR lifecycle Todo

Alchemy D1 clone

promote revision

Alchemy D1 clone

Alchemy D1 clone

beta advances

READY to STALE_BASE

production
released migrations
real data

beta candidate
sanitize + verify
approved migrations
CI / smoke

beta
stable hostname
released + approved

pr-481
own Worker + own D1
+ branch draft migrations
inherit auth, prefix R2

pr-490
own Worker + own D1
conflicting drafts OK

Promotion is logical; physical topology is many previews, one beta, one prod.
Diagram source (mermaid)
flowchart TB
  P[production<br/>released migrations<br/>real data] -- Alchemy D1 clone --> BC[beta candidate<br/>sanitize + verify<br/>approved migrations<br/>CI / smoke]
  BC -- promote revision --> B[beta<br/>stable hostname<br/>released + approved]
  B -- Alchemy D1 clone --> PR1[pr-481<br/>own Worker + own D1<br/>+ branch draft migrations<br/>inherit auth, prefix R2]
  B -- Alchemy D1 clone --> PR2[pr-490<br/>own Worker + own D1<br/>conflicting drafts OK]
  B -. beta advances .-> PR1
  PR1 -. READY to STALE_BASE .-> PR1
TaskFilesVerificationStatus
7.1 Extend pr_previews with environmentId and per-PR D1 (clone of validated beta) instead of the shared preview database; keep the existing hostname scheme and Workers custom domain attach.packages/db/src/schema/pr-preview.ts, packages/api/src/lib/preview.tsExisting live-proven chain (webhook → deploy → attach → live → comment) still passes on a generated appTodo
7.2 Push handling: reuse state, append new drafts in place, rebuild the preview DB on draft divergence (Phase 5 planner says rebuild).preview orchestrationTwo pushes with an edited draft → one rebuildTodo
7.3 Stale-base on beta advance (Phase 4.4), grace-period cleanup on close (reuse the existing destroy path).preview orchestration, reaperClose PR → destroyed after graceTodo
Acceptance: two open PRs with incompatible draft schemas both go live and neither sees the other's schema.

Phase 8 — Beta blue/green Todo

TaskFilesVerificationStatus
8.1 Build a beta candidate revision (Alchemy stage beta-candidate-<n>) from the latest data generation + approved migrations + release-candidate Worker.environment orchestrationCandidate has its own D1 and WorkerTodo
8.2 Backward-compat rehearsal: run the current production Worker revision and the candidate against the candidate schema; smoke/integration suite; optional N-1 rollback check.CI lane in the template + gate in ForgeGraphRehearsal results stored on the revisionTodo
8.3 Promote: repoint the stable beta hostname (custom domain) to the validated revision; retain the previous revision for a bake window, then destroy. Reuse the one-box traffic-shifting machinery where it fits.environment orchestration, DNS/custom-domain codeHostname flips without a 1104 window longer than the known ~90sTodo

Phase 9 — OTel reconciliation Todo

Decision C. Traces are exported to the external collector, not to ForgeGraph's Postgres. The SLA evaluator and observed edges need either (a) a query path from ForgeGraph to the trace store, or (b) a lightweight per-app aggregator that pushes operation-level RED numbers and peer edges to a new ingest endpoint. Recommend (b): it keeps the join inside ForgeGraph, matches the existing service_metrics shape, and needs no trace-store credentials in the hub.
TaskFilesVerificationStatus
9.1 Template: set fg.operation.id = `${serviceId}.${group}.${endpoint}` and fg.service.id on the server span in EndpointBoundaryLive; on client spans in @gmacko/api-client set fg.peer.operation.id.create-gmacko-app/packages/api/src/boundary.ts, packages/api-clientSpan attribute testTodo
9.2 @forgegraph/otel: same attributes for non-template apps; operation id becomes the span name key in service_metrics.packages/otel/src/*Existing otel tests + attribute assertionsTodo
9.3 Ingest: observed_edges (fromServiceId, toOperationId, stageId, window, count, errorCount, p50/p95/p99) via POST /api/fg/observed; aggregator ships in the template as a scheduled handler.packages/db/src/schema/observed-edge.ts, ingest routeRoute test; live: one edge appears after trafficTodo
9.4 Reconciler: join declared (contract_operations + declared dependencies), deployed (deployed_bindings service bindings), observed (observed_edges). Detect undeclared runtime dependency, declared-not-observed, wrong environment binding, unexpectedly public route, declared auth without matching middleware (from 1.5's mismatch flag), SLA violation.packages/api/src/lib/reconcile/*, alert rulesUnit per detector with fixture graphsTodo
9.5 UI: Declared / Deployed / Observed panel per operation with expected vs observed SLA leaves.operations/[operationId]/page.tsxBehavior testTodo

CI gates (target set)

  1. Effect contract compiles and contract.json matches its snapshot. (P1)
  2. IR validates; operation ids unique; SLA values valid. (P2)
  3. Public + anonymous mutations reviewed. (P2)
  4. Migration immutable-history check and destructive-SQL check pass. (P5)
  5. Alchemy plan succeeds. (P3b)
  6. Sanitizer verification passes; beta rehearsal passes; current + candidate Workers work against the candidate. (P6, P8)
  7. Smoke/integration pass. (existing)
  8. Declared bindings match deployed bindings. (P3)

Risks & Mitigations

RiskSeverityMitigation
Effect 4 is an rc; effect/unstable/httpapi can change shape under @forgegraph/contractHighPeer-pin to the template catalog; a ForgeGraph CI job runs the contract tests against that exact version; the compiler touches only reflect and public annotation accessors.
Alchemy 2.0 beta adoption in the template breaks the proven wrangler deploy pathHigh3a delivers deployed-graph value first. Adopt Alchemy behind a template flag; keep deploy-stage.mjs's wrangler branch until two stages have deployed via Alchemy for a release cycle.
Hosting Alchemy's state API in the hub couples ForgeGraph to an unstable wire formatMediumStore ResourceState verbatim in jsonb; only the correlation projection is typed; honor /version.
Two schema systems for the IR (Effect on the app side, Zod on the hub side) driftMediumThe ./ir entry owns the Zod schema and a generated JSON Schema; the Effect side validates its output against the same JSON Schema in tests.
New hub tables turn prod deploys red until DDL is applied (known schema-drift gate)MediumLand each migration in its own PR ahead of the code that reads it; apply DDL before merge per the existing runbook.
Contract POST adds a DB read on hot paths and breaks mocked route tests (known deploy-route mock trap)LowContract ingest is its own route; nothing is added to the deploy POST path.
Per-PR D1 clones multiply Cloudflare resources and costLowGrace-period reaper; clone only when the PR touches migrations or opts in; otherwise keep the shared preview DB.
Sanitizer misses PIIHighFail-closed verification (6.4) is a hard gate; beta cannot promote without a passing generation; rules are versioned and hashed on the generation row.

Verification

Open Questions

A. Adopt Alchemy? DECIDED: yes (2026-09-10). Adoption is under way. ForgeGraph now hosts Alchemy's state store (PR #571), which is the load-bearing half — correlation becomes a fact ForgeGraph holds rather than a join it reconstructs. Compatibility verified: alchemy 2.0.0-beta.77 peers on effect >=4.0.0-beta.105, satisfied by the template's 4.0.0-rc.112. Template-side alchemy.run.ts (3b.3) is next, behind a flag.
B. Should ForgeGraph host the Alchemy state store, or read a state store Alchemy keeps elsewhere? Recommended host it: correlation by construction, no scraping, and the hub already has the auth and app scoping. Cost is ten REST routes and one jsonb table.
C. Observed data path: query the trace store from the hub, or push operation-level aggregates from apps? Recommended push (9.3). It matches service_metrics, needs no collector credentials in the hub, and works for the fleet's non-template apps through @forgegraph/otel.
Smaller calls made in this revision (change them if you disagree): the new UI tab is named "API" with route key operations to avoid the existing health-probe endpoints route; authentication is derived from middleware and only overridden by annotation; staging is left outside the promotion semantics; .forgegraph.yaml gains only contract: true and previews.isolation; the plan file lives outside the protected seed checkout and should be committed to docs/plans/ from a worktree.

Recommended first PRs

  1. ForgeGraph: packages/contract with annotations, SLA resolver, compileHttpApi, IR + Zod, serializer, tests (tasks 1.1–1.7). No hub changes. Publish 0.1.0 to the private registry with pnpm (not npm, per the publishConfig trap).
  2. create-gmacko-app: annotate the domain, add sdks/contract, fold inspect.ts (tasks 1.8–1.10).
  3. ForgeGraph: tables + POST /api/fg/contracts + attestation type (2.1–2.3) in one PR with the migration applied ahead of merge; then router + tab (2.5–2.6); then the CLI subcommand and CI step (2.4).

Do not mix Alchemy adoption or environment provisioning into these. The vertical slice proves the thesis: the Effect contract is executable architecture metadata, and ForgeGraph can display and gate on it without asking anyone to repeat themselves.