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)
AppApi with groups auth/posts/settings/admin/health; credentials are middleware tags (Session, SessionOrKey(scope)) in security.ts.compileHttpApi.RateLimitScopeAnnotation is a Context.Service read from endpoint annotations. Context.Reference(key, {defaultValue}) is available in rc.112 for the private-by-default vocabulary.EndpointBoundaryLive names the server span `${group.identifier}.${endpoint.identifier}`. Prefixing the service id gives the canonical operation id for free.OpenApi.fromApi(AppApi) with a snapshot test and drift check. The ForgeGraph IR export slots in beside it.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).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).evidenceRef/evidenceSha, waivers, merge gating. A contract snapshot is one more attestation type.no-d1-table-rebuild.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.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.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.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).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".Architecture
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
| Piece | Repo / path | Why there |
|---|---|---|
@forgegraph/contract — annotations, SLA resolver, compileHttpApi, IR types, serializer | ForgeGraph packages/contract, published to the private registry like @forgegraph/otel | Shared 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 API | create-gmacko-app packages/domain, sdks/contract | The template is the "one real service converted". Every generated app inherits it. |
| Ingest, storage, gates, UI | ForgeGraph apps/web/src/app/api/fg/contracts, packages/db, packages/api/src/routers/contract.ts, apps/web/src/app/apps/[id]/operations-tab.tsx | Zod-validated JSON at the boundary; no Effect in the hub. |
| Deployed-graph importer | ForgeGraph packages/api/src/lib/deployed-graph/ (+ agent handler for Alchemy state) | Two readers, one output shape. |
| Migration manifest + lint | ForgeGraph agent/internal/migrationlint (Go) exposed via fg migration check and fg-check | Runs 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.reflectis 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.tsstay as-is. - A parallel YAML DSL.
.forgegraph.yamlgains nothing endpoint-shaped. - D1 copy-on-write branching.
DatabaseMaterializeris 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.
- R1 Do not duplicate Effect API declarations; manual adapters only for non-Effect / imported OpenAPI / external APIs.
- R2 Operation identity is semantic:
people-api.users.getById, neverGET /users/:id. - R3 SLA precedence global → service → API → group → endpoint → environment, merged per leaf, provenance persisted.
- R4
IsPublic(default false) is orthogonal toAuthentication(anonymous | user | service | agent | mixed). - R5 Environment is a complete graph resolution, not "Worker + DB".
- R6
devis a class of many disposable environments; one beta; one prod. In ForgeGraph terms:development/PR previews →beta→production.stagingstays a plain stage; it is not part of the promotion semantics this plan adds. - R7 Beta is a rebuildable release rehearsal on sanitized production-derived data.
- R8 Migration history has governance state draft → approved → released; preview = released + approved + draft, beta = released + approved, prod = released.
Milestones
- Phase 1 — @forgegraph/contract + annotations + compiler
Done 2026-09-09.@forgegraph/contract@0.1.0published to the private registry and cold-install verified. ForgeGraph PR #564 (package, 37 tests, CI green) and create-gmacko-app PR #5 (annotations, 35-operation snapshot, generator, drift check, 77 tests). Publishing was blocked for an hour by a garbage-collected registry store path, fixed and made durable by PR #565. - Phase 2 — Ingest + Operations tab
Done 2026-09-09. Four stacked PRs: #567 tables +POST /api/fg/contracts+ theapi_contractgate, #568 the contract router and API tab, #569fg contract publish. 96 new tests. The migration must be applied before #567 merges. - Phase 3 — Deployed graph
3a done 2026-09-10 (PR #570): declared-vs-deployed binding drift, computed on demand. Thedeployed_resourcestables were deliberately deferred — a cached row goes stale in exactly the situation drift detection exists to catch, so this ships with no migration. 3b (Alchemy state store) still waits on Decision A. - Phase 4 — Environment IR + resolver
Environment / EnvironmentRevision overstages+pr_previews; isolation strategies. - Phase 5 — Migration governance
Manifest, states, planner, destructive-SQL lint infg-check. Independent of 3–4. - Phase 6 — Sanitized DataGeneration
Clone → sanitize → verify → lineage. Needs 3b or a wrangler export/import shim. Decision B - Phase 7 — PR lifecycle
Own D1 per PR, draft migrations in place, stale-base, grace cleanup. Replaces the shared preview DB + migration guard. - Phase 8 — Beta blue/green
Candidate → validate → promote identity → retire. - Phase 9 — OTel reconciliation
fg.operation.idon spans, observed edges, SLA evaluator, drift alerts. Decision C
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.
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
| Task | Files | Verification | Status |
|---|---|---|---|
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.ts | pnpm -F @forgegraph/contract build; importing ./ir from a file with no effect installed type-checks | Done |
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.ts | Unit: absent annotation returns default; Context.get(mergedAnnotations, IsPublic) on an unannotated endpoint is false | Done |
| 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) rejected | Done |
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.ts | Unit against a fixture HttpApi with 2 groups × 3 endpoints; snapshot of the IR | Done |
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.ts | Unit: template-shaped Session → user, SessionOrKey → mixed, none → anonymous; override sets the mismatch flag | Done |
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.ts | Unit: Zod parses the fixture IR; unique-id check fails on a duplicate operation id | Done |
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.ts | Unit: same schema twice → same fingerprint; reordering endpoints does not change the contract fingerprint; renaming a field does | Done |
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}.ts | Existing domain tests still pass; inspectApi output unchanged | Done |
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 tests | pnpm -F @gmacko/contract-spec generate; snapshot pinned; CI drift check red when an endpoint changes without a snapshot update | Done |
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.ts | API_AUTH matrix regenerates byte-identical | Done (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.
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.
| Task | Files | Verification | Status |
|---|---|---|---|
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.sql | Schema-shape test like app.test.ts; db:push against a dev DB | Done |
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.ts | Route test: valid IR → 201; duplicate op id → 422; wrong workspace → 404 | Done |
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 boolean | Existing attestation tests + one new: PR without contract on an opted-in repo is blocked | Done |
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 workflow | Go unit test on the request shape; a real PR on a generated app shows the attestation satisfied | Done |
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.ts | Router test with two snapshots and a beta deployment on the older one → beta shows the older contract | Done |
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.tsx | Behavior test (*.behavior.test.tsx) for the tab; storybook fixture for the operation row | Done |
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.ts | Unit: POST /signup public+anonymous flags; GET /health does not | Done |
Phase 3 — Deployed graph: Cloudflare reader first, Alchemy state second Todo
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.| Task | Files | Verification | Status |
|---|---|---|---|
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-*.ts | Schema-shape tests | Deferred |
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.ts | Unit with recorded API fixtures; live: one app shows its D1 binding as declared+deployed | Done |
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.tsx | Deploy a change that adds a binding → drift clears after the run | Done |
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.ts | Schema test; mapping function unit test | Done |
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-trips | Done |
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.yaml | Staging deploy via ForgeGraph succeeds; 3b.1 rows populated; Resources tab shows source = alchemy | Todo |
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.ts | Both readers produce identical rows for the same deployed app | Todo |
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.
| Task | Files | Verification | Status |
|---|---|---|---|
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 preview | Todo |
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.ts | Unit: resolver returns a complete graph for a preview with the example mix | Todo |
| 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.ts | Golden test: beta → pr-481 resolution matches the draft's example table | Todo |
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.ts | Unit: beta promote → preview flips; rebuild clears | Todo |
| 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.
| Task | Files | Verification | Status |
|---|---|---|---|
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.mjs | Go + node tests: deterministic output; hash changes when SQL changes | Todo |
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 completion | Integration test through the deploy completion path | Todo |
| 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-check | Table-driven Go tests for all seven rows | Todo |
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.go | Fixture SQL corpus; both SQLite and Postgres dialects | Todo |
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.ts | Existing preview tests updated | Todo |
Phase 6 — Sanitized DataGeneration Todo
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.| Task | Files | Verification | Status |
|---|---|---|---|
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.ts | Schema test | Todo |
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 handler | Live: clone a small D1 and row-count both sides | Todo |
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 agent | Run twice → identical output hash | Todo |
| 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.go | Fixture DB with planted PII fails; clean DB passes | Todo |
| 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.ts | End to end on the template app's beta | Todo |
Phase 7 — PR lifecycle Todo
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
| Task | Files | Verification | Status |
|---|---|---|---|
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.ts | Existing live-proven chain (webhook → deploy → attach → live → comment) still passes on a generated app | Todo |
| 7.2 Push handling: reuse state, append new drafts in place, rebuild the preview DB on draft divergence (Phase 5 planner says rebuild). | preview orchestration | Two pushes with an edited draft → one rebuild | Todo |
| 7.3 Stale-base on beta advance (Phase 4.4), grace-period cleanup on close (reuse the existing destroy path). | preview orchestration, reaper | Close PR → destroyed after grace | Todo |
Phase 8 — Beta blue/green Todo
| Task | Files | Verification | Status |
|---|---|---|---|
8.1 Build a beta candidate revision (Alchemy stage beta-candidate-<n>) from the latest data generation + approved migrations + release-candidate Worker. | environment orchestration | Candidate has its own D1 and Worker | Todo |
| 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 ForgeGraph | Rehearsal results stored on the revision | Todo |
| 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 code | Hostname flips without a 1104 window longer than the known ~90s | Todo |
Phase 9 — OTel reconciliation Todo
service_metrics shape, and needs no trace-store credentials in the hub.| Task | Files | Verification | Status |
|---|---|---|---|
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-client | Span attribute test | Todo |
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 assertions | Todo |
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 route | Route test; live: one edge appears after traffic | Todo |
| 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 rules | Unit per detector with fixture graphs | Todo |
| 9.5 UI: Declared / Deployed / Observed panel per operation with expected vs observed SLA leaves. | operations/[operationId]/page.tsx | Behavior test | Todo |
CI gates (target set)
- Effect contract compiles and
contract.jsonmatches its snapshot. (P1) - IR validates; operation ids unique; SLA values valid. (P2)
- Public + anonymous mutations reviewed. (P2)
- Migration immutable-history check and destructive-SQL check pass. (P5)
- Alchemy plan succeeds. (P3b)
- Sanitizer verification passes; beta rehearsal passes; current + candidate Workers work against the candidate. (P6, P8)
- Smoke/integration pass. (existing)
- Declared bindings match deployed bindings. (P3)
Risks & Mitigations
| Risk | Severity | Mitigation |
|---|---|---|
Effect 4 is an rc; effect/unstable/httpapi can change shape under @forgegraph/contract | High | Peer-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 path | High | 3a 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 format | Medium | Store 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) drift | Medium | The ./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) | Medium | Land 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) | Low | Contract ingest is its own route; nothing is added to the deploy POST path. |
| Per-PR D1 clones multiply Cloudflare resources and cost | Low | Grace-period reaper; clone only when the PR touches migrations or opts in; otherwise keep the shared preview DB. |
| Sanitizer misses PII | High | Fail-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
- Automated (ForgeGraph):
pnpm -F @forgegraph/contract test;pnpm -F @forgegraph/api test(routers, reconcile, environment);pnpm -F @forgegraph/db testschema shapes;go test ./agent/internal/migrationlint/...; repo-wide typecheck (mind the web tsc heap ceiling). - Automated (template): domain contract snapshot + drift check; span attribute tests; standards check unchanged.
- Live, Phase 1–2: open a PR on a generated app that renames an endpoint; the ForgeGraph API tab shows the new operation id on the next CI run and the
api-contractattestation is satisfied, with no config file edited. - Live, Phase 3: Resources tab shows declared vs deployed for staging with zero drift after a deploy; add a binding in wrangler only → drift appears.
- Live, Phase 7: two PRs with conflicting draft migrations both reach
live.
Open Questions
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.
service_metrics, needs no collector credentials in the hub, and works for the fleet's non-template apps through @forgegraph/otel.
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
- ForgeGraph:
packages/contractwith annotations, SLA resolver,compileHttpApi, IR + Zod, serializer, tests (tasks 1.1–1.7). No hub changes. Publish0.1.0to the private registry with pnpm (not npm, per the publishConfig trap). - create-gmacko-app: annotate the domain, add
sdks/contract, foldinspect.ts(tasks 1.8–1.10). - 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.