造物工坊 · Foundry Core
共享后端的第一版提供 Google 登录。各项目通过同一个 HTTPS API 登录,在私有 PostgreSQL 的认证库中维护独立账号和会话。项目业务库可分别配置独立的数据库和角色。
服务端配置
- 在 Google Cloud 创建一个 Web application OAuth 客户端。授权重定向 URI 填写
https://<API 域名>/auth/google/callback。所有项目信任同一个客户端,因此共享 Google 同意页面的应用名称。 - 在服务器私网准备 PostgreSQL 13 或以上版本。在认证库执行一次
db/001-auth.sql(使用psql -v ON_ERROR_STOP=1),给运行账号授予这四张表所需的查询及增删改权限。 - 按
.env.example注入运行环境变量。每个项目配置一个固定的前端回调地址,本地开发可用 localhost。将 Google 客户端密钥和数据库连接串存入服务器的密钥管理设施。 - 由 Gitea Actions 在
ces-control上使用 Go 1.25 构建并检查。部署工作流仅处理main上影响后端或部署的改动,将二进制启动在服务器 Docker 私网,由 Traefik 提供 HTTPS。通过GET /healthz检查进程,通过GET /readyz检查认证库。 - 代理避免记录 Google 回调的查询参数及认证请求头。设置入口限流,并定时删除三张临时表(
oauth_attempts、login_grants、project_sessions)中expires_at <= now()的记录。
共享论文库(Thesisly)
可选只读接入见共享论文 API。Thesisly 使用同一后端的 /projects/{project}/research/papers 列表、搜索和详情;只有配置服务器上的 FOUNDRY_RESEARCH_DATABASE_URL 时才启用。连接串保存在受保护的 research.env,不放入前端或 Actions Variables。当前仍等待 Paper Atlas 全量 PostgreSQL 迁移完成及真实数据验收,尚未启用线上论文接口。
Actions 部署配置
在仓库的 Actions Secrets 配置 GOOGLE_CLIENT_ID 和 GOOGLE_CLIENT_SECRET,在 Actions Variables 配置 FOUNDRY_PROJECTS。变量值为 JSON 对象,例如 {"prompt":"http://localhost:17321/auth/callback"}。部署读取服务器上预先配置的 /opt/endoleap-infra/foundry/database.env,OAuth 值由 Actions 在启动容器时注入。
Google OAuth 的授权回调地址为 https://api.foundry.entroia.com/auth/google/callback。工作流会验证数据库就绪、匿名请求返回 401 和 HTTPS 就绪。前端接入后,可在浏览器用 Google 测试账号验证完整登录、回调、兑换和会话。
前端接入
- 在浏览器用 Web Crypto 生成 32 字节随机值,编码为无填充 Base64URL,作为 verifier 暂存在当前 tab 的 sessionStorage。对 verifier 字符串做 SHA-256 并用相同编码得到 challenge。
- 顶层导航到
GET /projects/<project>/auth/google?challenge=<challenge>。Google 回到共享 API 后,API 将浏览器重定向至已配置的项目回调。 - 回调页从 URL fragment 读取
code或error,用 history.replaceState 清掉 fragment。收到 code 时,向POST /projects/<project>/auth/exchange发送X-Foundry-Code和X-Foundry-Verifier请求头,随后清理暂存的 verifier。code 有效期一分钟,仅可使用一次。 - 将返回的八小时会话凭证保存在内存中,使用
Authorization: Bearer <access_token>调用GET /projects/<project>/auth/me。通过POST /projects/<project>/auth/logout撤销会话并清理本地凭证。刷新页面后,内存凭证需要通过重新登录获取。
同一个 Google 账号首次进入不同项目时,会分别创建用户 ID。会话绑定其签发项目。Google 账号的标识 sub 用于认领同一项目的既有账号,邮箱只作为资料字段。当前注册模式为开放注册。
验收
用同一个 Google 测试账号分别登录两个项目,确认用户 ID 不同。用 A 的会话请求 B 的 me 应返回 401。验证兑换码重放被拒绝,并确认退出 A 后 B 的会话仍有效。
Runtime HTTPS trust
The Go backend needs an operating-system CA bundle for Google and Slack HTTPS
requests. The node:24-bookworm-slim runtime does not include that trust store;
Node's own successful HTTPS checks do not validate Go's trust configuration.
deploy/release.sh mounts the deployment host's
/etc/ssl/certs/ca-certificates.crt read-only and sets SSL_CERT_FILE to that
path. The deployment fails before switching traffic if the host bundle is
missing or unreadable in the candidate. Keep TLS certificate verification
enabled and maintain the deployment host's trusted CA package.
Codex execution
When enabled, POST /projects/{project}/codex/exec accepts a JSON object
containing only a nonempty prompt string and returns {"result":"…"}.
Use Authorization: Bearer <token> with a dedicated 32-byte random token
encoded as 64 hex characters. Ordinary OAuth sessions cannot execute Codex.
Missing, unknown and revoked tokens return 401. A valid token for a different
project returns 403. Requests are limited to 32 KiB, CLI output to 512 KiB,
and each execution to 90 seconds. The worker accepts one execution at a time
and returns 429 when busy.
On the deployment host, provision /opt/endoleap-infra/foundry/codex with:
grants/projects.jsoncontaining a JSON object mapping lowercase hex SHA-256 digests of the decoded token bytes to configured project names. Use mode 0700 for the directory and 0600 for the file, readable by UID 1000. Rotate or revoke tokens by atomically replacing the file within the mounted directory. A revocation applies to subsequent requests.homeowned by UID 1000 with an isolated Codex login inauth.json. Keep this home dedicated to the worker. Supply the login only and keep user configuration, MCP servers, skills, and other workload files out of it.
With that directory configured, the next normal Foundry deployment starts the
worker using the Linux binary built by ces-control and mounts the worker socket
and grants directory read-only. Normal backend deployments replace the worker with the same reviewed Linux build
and verify its private model catalog, restoring the previous worker if startup
fails. For manual maintenance, stop the managed worker before running
bash deploy/codex-worker.sh /absolute/path/to/foundry. The worker
runs the installed Codex CLI with fixed read-only, no-approval arguments and
disables command execution tools. It receives prompts via stdin and returns
the final agent message. The worker has no published port and Foundry receives
neither the Codex home nor the Docker socket. Keep both containers on the same
host for Unix socket access.
Board Canvas snapshots and slugs
Apply db/003-canvas.sql through the normal migration runner before starting this
version. GET /projects/{project}/workspaces/{workspace}/materials/{board}/canvas
returns {version, document} and a quoted ETag. An untouched board returns
version 1 and an empty schema-1 graph. PATCH takes {document: <full snapshot>}
and the quoted If-Match; it atomically replaces the snapshot and returns
{version} with the new ETag. Canvas and material metadata have separate
versions. GET uses existing material read permissions; PATCH requires editor or
admin. Missing versions return 428, stale versions 412 version_conflict, invalid
graphs 400 invalid_canvas, and bodies over 5 MiB (UTF-8 bytes) 413. PNG/JPEG/WebP/
GIF data URLs up to 2 MiB decoded and HTTPS image URLs are supported. Temporary
node selection/drag/measurement state and mirrored styles are discarded.
Workspace and material create/get/list responses include slug. Create and
metadata PATCH accept an optional slug (1–100 lowercase ASCII letters/digits,
with single hyphens between segments; UUID-form custom values are reserved).
Workspace slugs are unique within a project, material slugs within a workspace;
duplicates return 409. Defaults use the ASCII name/title and UUID, falling back
to a generic prefix for names without ASCII letters/digits. Existing records are
backfilled. Renaming leaves slugs stable unless explicitly supplied. Workspace
and material path parameters accept either ID or slug, with unchanged permissions.
Canvas uses its board material's slug; there is no separate Canvas identity.
Slugs enable frontend SEO URLs but do not make private content public or add
search-engine metadata. Changing a custom slug does not retain an old alias.
Frontend follow-up: allowlist the Canvas proxy route and set its body limit to at least 5 MiB; load the server snapshot, offer migration of old local drafts, serialize autosaves and handle 412 explicitly. Restore node style dimensions from width/height and render edges as Bézier curves. These frontend changes are outside this Core delivery; local drafts alone are not database persistence.
Canvas schema 1 supports unframed images: type: "plainImage" with
data.kind: "plain-image". They require the same validated data.src as image
cards, support geometry/rotation/frame membership, and cannot connect to edges.
This is a snapshot-format extension with no additional migration.
Canvas schema 1 also supports frames: type: "frame" with data.kind: "frame".
Frame names use data.title; optional data.labelMode is hidden, fixed, or
scaled (frontend default: fixed). Frames cannot rotate, connect to edges,
or belong to other frames. Any non-frame node can specify data.frameId, which
must reference a frame in the same snapshot; forward references are supported.
Membership is explicit and independent of geometry: moving a frame over existing
content does not add members. The frontend assigns membership on content drop
and implements label zoom behavior. Core preserves the supplied membership and
label mode without inferring them from positions. Renderer-only zIndex is
ignored alongside transient state; restore frame layering in the frontend.
This extends existing JSONB snapshots and requires no additional migration.
Browser session lifetime
Sessions use random, revocable bearer tokens rather than JWTs. New email and OAuth logins have a 30-day idle lifetime; authenticated requests atomically renew that idle deadline, capped at 90 days from issuance. Idle-expired, absolute-expired, revoked and wrong-project tokens are never revived. Logout removes the session.
Apply db/004-session-lifetime.sql before running this version. Existing sessions
keep their prior idle expiry, so expired logins remain expired. For legacy rows,
the absolute 90-day cap starts at migration time because issuance was not recorded;
an unexpired legacy session gains the new idle lifetime on its next authenticated
request. New rows record their actual creation time and absolute deadline.
Login and exchange responses return expires_in: 2592000. Protected platform
responses and /auth/me include X-Foundry-Session-Expires-In with the remaining
seconds after renewal, shortened near the absolute deadline. The frontend proxy
must renew its HttpOnly cookie using that value, with a maximum of 30 days. It must
not extend a cookie beyond the backend-provided remaining lifetime, or expose the
session token to client JavaScript. CORS exposes the lifetime header alongside
ETag where applicable. The absolute deadline stays server-authoritative.
Canvas V2 contract
Contract version: canvas-v2-2026-11-06. These routes are implemented on the
Canvas V2 work branch; they become available after that branch is reviewed,
merged and deployed. Do not infer production availability from this document.
B below means /projects/{project}/workspaces/{workspace}/materials/{board}/canvas.
Workspace and board path segments accept canonical UUIDs or existing slugs.
Responses and child relationships use canonical IDs. Authentication is the
existing user session Bearer token, with inherited material/workspace
permissions; the old project Codex service token is not accepted as a user login.
Reader/viewer accounts may read; mutations require editor/admin access.
The existing 30-day idle renewal and 90-day absolute session expiry are unchanged.
Snapshot compatibility and bounds
Use Accept: application/vnd.foundry.canvas+json;version=2 for V2 clients.
GET remains {version,document}; PATCH accepts {document} and returns {version}.
The actual schema is in document.schema_version and X-Canvas-Schema-Version;
ETag: "N" is the independent canvas version. PATCH requires If-Match: "N":
missing versions return 428, stale versions return 412 with zero writes.
V1 is still supported. Once a board is V2, legacy GET/PATCH and schema downgrades
return 409 canvas_schema_upgrade_required; no lossy V1 projection is returned.
GET does not create or migrate child boards.
Each canvas permits 500 nodes, 2,000 edges and 5 MiB of snapshot request data. Coordinates and control vectors are finite, within ±1,000,000. Ordinary node width/height are 20–3,000; V2 frame dimensions are 32–100,000. Ephemeral selection, dragging, measured geometry and editor gesture state are not saved.
V2 edges preserve typed data.appearance, animation, path and execution:
| Field | Values / bounds |
|---|---|
| appearance | six-digit hex color; width 0.5–20; opacity 0.05–1; solid/dashed/dotted |
| head / tail | none/open-arrow/triangle/circle/diamond/square; size 4–32; boolean filled |
| animation | off/always/executing; flow/dash/pulse; forward/reverse; durationMs 250–30,000; hex color |
| path | bezier-chain; optional startControl/endControl; up to 64 ordered, uniquely identified knots |
| knot | position and relative in/out vectors; corner/smooth/symmetric; collinearity/length tolerance 1e-6 |
| execution.enabled | boolean; omitted means enabled; animation direction does not reverse dependencies |
Omitted appearance/animation fields remain omitted and use the original client
defaults (color #AA968B, width 1.5, solid, no markers, animation off).
No arbitrary CSS, SVG path strings, marker URLs or callbacks are accepted.
Text/script/image/plain-text/plain-image nodes can connect; icons cannot.
Frames connect through declared port:<id> handles, including nested port chains.
GET B/capabilities reports schema versions, actual worker availability and
limits. A configured catalog alone does not imply that every model can generate
images: image variants are enabled only for backend models explicitly verified
by the operator.
Subgraphs and cross-canvas transactions
Each linked frame owns one child board in the same project/workspace. The maximum
nesting depth is eight. The parent stores the frame and boundary edges; member
nodes and internal edges have exactly one owner, the child canvas. Child reads
and writes inherit the parent chain, including when addressed directly by ID.
Material lists omit child boards by default; include_subgraphs=true includes
accessible children with parent_board_id and frame_id.
Frame data retains subgraph.boardId, ports, and presentation:
collapsed, contentScale (0.05–4), expandedSize, collapsedSize.
Collapsing/scaling presentation does not rewrite child content. Projection is
frame.position + (24,36) + contentScale * child.position; viewport is independent.
| Route | Required input / behavior |
|---|---|
| POST B/connections | {source,target,edge,expected_versions} + Idempotency-Key; atomically creates/reuses boundary port chains and stores one edge in the lowest common ancestor |
| POST B/subgraphs | Parent If-Match; {frame:{id,title,position,width,height,labelMode}}; returns 201, child_board, parent/child snapshots and receipt |
| POST B/subgraphs/{frame}/members | {source_board_id,target_board_id,node_ids,expected_versions}; parent/child or siblings coordinated by their common parent |
| POST B/subgraphs/{frame}/dissolve | {expected_versions}; preserves content in the parent and archives the emptied child |
| POST B/subgraphs/migrate | {dry_run:true} creates no data; returns plan_id, digest, parent version and explicit members/edges/issues |
| POST B/subgraphs/migrate | {dry_run:false,plan_id} plus parent If-Match; stale plans return 412 |
| POST B/transactions/{id}/undo | {expected_versions}; restores snapshots and relationships atomically, increments current versions |
All mutating commands above require a UUID Idempotency-Key (case-insensitive).
Scope is project/workspace/user/resource/action. Canonical payload retries return
the same operation; a different payload returns 409 idempotency_conflict.
Records remain available for at least 24 hours. Read-only migration preflight
requires editor/admin but does not require an idempotency key or create a plan row.
expected_versions must include every modified canvas, including an ancestor
whose boundary port must change and child canvases whose ownership changes.
Missing versions return 428; any stale version returns 412 before commit.
All snapshots, versions, relationships, idempotency and receipt records share a
single SQL transaction. Boundary edges retain their IDs/styles/paths; ports are
stable and deduplicated. Node ID collisions and graph capacity errors reject the
entire command. Ordinary PATCH cannot detach linked frames, retarget ownership
or remove nodes still referenced across a boundary.
Undo receipts have an absolute expires_at and a seven-day lifetime; retries do
not extend it. Expired undo returns 409 undo_receipt_expired. Undo also checks
recorded after-state and current relationships, so incompatible edits return 412.
Undoing a later operation can restore a compatible state for an earlier undo
without resetting version counters. Redo uses the original command and a new key.
Migration considers only explicit legacy frameId membership, including empty
frames. It preserves internal and boundary edges, image references and styling,
converts coordinates/control points, and skips already linked frames. Original
snapshots and mapping data remain in the operation record for rollback.
Assets
POST /projects/{project}/workspaces/{workspace}/assets takes one multipart part
named image (or file) and an Idempotency-Key. PNG/JPEG/WebP/GIF are decoded,
not trusted by filename. The limit is 20 MiB and 40 million decoded pixels.
The response is {id,mime_type,bytes,width,height,url}. PostgreSQL stores the
encoded object, metadata and bindings atomically.
GET .../assets/{id} requires a user session and returns the actual image bytes
with the detected MIME and nosniff. Unbound objects are readable only by their
uploader or workspace admins; bound objects require access to an owning material
through its current parent chain. URLs are stable HTTPS application URLs, never
worker files or expiring signed URLs. Browser integrations should use an
authenticated fetch/proxy; do not persist temporary blob URLs in snapshots.
data.assetId is optional when src is already a stable managed URL. Moves,
dissolve and undo retain bindings; referenced images are not prematurely collected.
AI image inputs are frozen as authorized asset references. Existing inline images and public HTTPS node images can be validated and frozen by Core. Fetches enforce HTTPS, DNS/IP checks, bounded redirects/bytes/time, and reject private, loopback, link-local and cloud metadata addresses. The worker receives validated bytes over private Unix IPC, not arbitrary external download instructions.
AI models and runs
GET B/ai/models returns opaque IDs derived from the worker's real CLI catalog,
modalities, availability, input/timeout bounds and parameter schemas. Current CLI
variants expose an empty parameter object with additionalProperties:false;
raw CLI arguments and unknown parameters are rejected. No token, auth file, raw
CLI output or service configuration is included in browser responses.
Node data.ai stores enabled/modelId/prompt/propagate/parameters. Prompt length
is at most 16,000 characters; parameters encode to at most 16 KiB. Global AI panel
state, tool position and animation progress are client state, not snapshot data.
| Route | Input / result |
|---|---|
| POST B/ai/runs | If-Match + Idempotency-Key; {node_id,model_id,prompt,parameters,propagate}; returns 202 with run_id, poll_url, plan, canvas_version and ETag |
| GET B/ai/runs/{id} | after_seq >= 0, limit 1–100; status, plan, steps, events, next_seq, has_more, output metadata and applied_versions |
| GET B/ai/runs | status=active or a terminal status; include_descendants=true/false; limit 1–50; opaque next_cursor |
| POST B/ai/runs/{id}/cancel | Idempotency-Key; empty object/body; returns preserved applied steps and cancelled steps |
| POST B/ai/runs/{id}/apply | If-Match + Idempotency-Key; {node_id,output_id}; explicitly applies retained conflict output to its current target canvas |
Run admission atomically saves node configuration, increments the canvas version,
freezes the plan/fingerprints and records the task and key. A node has at most one
active step; another key targeting a busy node returns 409 node_busy. Queue
capacity is 128 active runs per project (429 queue_full with Retry-After).
Admission body is at most 256 KiB. Assembled model text is at most 1 MiB; inputs
allow four distinct images totaling 20 MiB. Text timeout is 90 seconds; verified
image variants use 180 seconds. The old /codex/exec limits remain unchanged.
Propagation is opt-in. It resolves nested ports to logical nodes, rejects cycles and plans over 64 steps/depth 32 before invoking the worker, and stops at unconfigured nodes. A topological schedule runs each node once; diamond joins wait for every participating predecessor. Other referenced inputs are frozen from their original snapshots. Incoming logical edge IDs order inputs stably. Only connected canvases/port chains/static inputs are read, not every child board.
Runs persist across HTTP disconnects and Core restarts. Queue leases serialize
execution; expired uncertain executions fail with execution_interrupted rather
than being silently reissued. A worker's explicit pre-execution busy response is
safe to requeue, with completed predecessor results recovered from the database.
Worker output updates only current target text or src/assetId in a SQL transaction. Node identity, dimensions, position, title and styling are retained. Content/AI configuration changes produce conflict and retain the output; geometry-only edits do not. Deletion, recreation, kind/ownership changes, revoked permission or cancel prevent late writes. Image output must be an actual decoded managed image; textual URLs are never accepted as completed image generation.
Events have monotonic per-run seq values and are replayable through polling. Run states: queued/running/succeeded/partially_succeeded/failed/cancelled/conflict. Step states additionally include skipped. Edge active/completed/failed/skipped events drive execution-only animation; reverse visual animation does not reverse execution. Cancellation commits terminal step states before acknowledging, retains already applied results, and stops the worker through request cancellation at its next heartbeat. It never implicitly rolls back applied output.
Text output is capped at 100,000 characters; failures keep original content. Poll event pages are bounded to 100 events and approximately 800 KiB of event data. Output metadata includes output IDs and image asset metadata; text content patches are in replayable step.output/step.applied events. Runs/events are retained for at least 30 days; referenced assets have no automatic destructive cleanup.
Errors use {error,message?,details?,request_id?}. Important statuses include
400 invalid_canvas/invalid_ai_request, 409 schema/idempotency/ownership/cycle
conflicts, 412 version_conflict, 428 version_required, 413 graph/context/asset
limits, 422 model_unavailable/model_incompatible and 429 queue_full. Asynchronous
generation failures are terminal step/run results, not successful empty content.
Remote verification and deployment
Normal ces-control CI runs formatting, tests, builds, migration checks and
isolated PostgreSQL integration tests. deploy/check-canvas-real.sh bin/foundry
is an explicit remote-runner-only real-model verification; it does not run in
normal CI. Set FOUNDRY_CANVAS_IMAGE_MODELS to backend names already verified for
image output. The check starts a temporary CLI worker and database and removes
both afterwards; the Core/test container never receives CLI login files.
On 2026-11-06, the isolated real check succeeded with text model
codex-18061daa6ba6e3a576dda453d0df839f and image variant
codex-42cfda1d607e416417a1a0ac5c73d437, backed by the installed CLI's
gpt-6.1-sol. The image decoded as PNG, 1254×1254, 857,723 bytes. These are
verification observations, not a hardcoded catalog or a production deployment
claim; temporary run/asset records were cleaned up. Always query the live catalog.
The isolated text run was a38ea762-d3bf-41c2-a43a-679e5785f3d7; the image run
was 6c0beaa0-d182-45b5-9075-110fcc800218 and its authenticated reread asset was
bf2733bc-8aca-4bca-97dc-3cfa52d48270, SHA-256
2ffda6b1603fd0bab27f63479d7f2ad316b65f663afc57ba3f8fefa92a8edce4.
These cleaned-up isolated IDs are audit observations, not live production resources.
For production, the worker can load operator-controlled codex/canvas.env with
FOUNDRY_CANVAS_IMAGE_MODELS containing verified backend names. Replace the worker
with the reviewed Linux build through the normal deployment process, apply the
numbered migrations and confirm capabilities before migrating a real canvas.
Production deployment of merge commit b4e537b4adedeb524971cbbdd3f52c61257b10da
succeeded on the confirmed ECS runner on 2026-11-06. Main checks
run 912
and deployment
run 913
both succeeded. The base URL is https://api.foundry.entroia.com.
The deployed capabilities endpoint reported contract canvas-v2-2026-11-06
with both text and image available; the worker uses the backend verified above.
Only the user-selected existing test canvas was migrated through the production HTTPS API, using a temporary operator session with an absolute three-minute expiry and the existing account's editor/admin permissions. Each preflight or verification session was revoked immediately afterwards; no credential was printed or persisted. The dry-run reported no issues, one legacy frame and two explicit members. The version-bound migration committed atomically:
| Item | Verified value |
|---|---|
| Project | endoleap |
| Canonical workspace | 8d775c90-74c9-4109-a1ad-6d0ad3a00ac7 |
| Canonical parent board | a9f9a985-e99a-43eb-b088-7edc9301452f |
| Parent version/schema | 209 / V1 → 210 / V2 |
| Frame | f2a00495-3740-43a6-aa52-611e4dd8520f |
| Canonical child board | 9c9bd83b-2444-41f8-8e0e-bfd6d8873577 |
| Child version | 1 / V2 |
| Transaction | 4a6e6fee-683e-4025-8ef8-47092a80331b |
| Undo expiry | 2026-11-13T10:56:40.241092Z |
| Nodes | 11 preserved: parent 9, child 2 |
| Edges | 2 preserved in parent: 1 mapped boundary edge, 1 unchanged parent edge |
Authenticated V2 rereads matched the committed snapshots. The two explicit
members retained their content, image references, dimensions and styles, with
positions converted to child coordinates. The boundary edge retained its ID,
style and path, and references a stable port targeting the original node/handle.
Retrying the same operation returned the same transaction; a fresh dry-run
reported zero additional children. A legacy GET returned
409 canvas_schema_upgrade_required. No other canvas was migrated.
The source snapshot, plan, complete transaction response, reread snapshots and
summary are retained with mode 0600 under
/opt/endoleap-infra/foundry/canvas-v2-migration-a9f9a985-e99a-43eb-b088-7edc9301452f
on the ECS host, in addition to the database receipt. The frontend V2 integration
remains a separate task; use a V2-aware client for this migrated test canvas.
Creating new cross-boundary connections
Ordinary Canvas PATCH preserves existing frame ports and cannot add or retarget ports. Migration/movement-generated ports are reusable, but creating a new external connection to an unexposed child node requires this dedicated command:
POST B/connections with user session authorization,
Accept: application/vnd.foundry.canvas+json;version=2, JSON content type and a
UUID Idempotency-Key. Availability is advertised as
capabilities.subgraphs.connections: true; clients must check the deployed
capability before calling it.
B identifies the lowest common ancestor board of the two actual endpoints.
The coordinator may itself be a nested board. Both endpoints must be in this
same project/workspace and inside its active subgraph tree. A direct endpoint on
B is valid, as are siblings and descendants up to the existing depth limit.
When both endpoints are in the same board, use that board as B.
{
"source": {"board_id":"<child-board-uuid>","node_id":"child-text","handle":"right"},
"target": {"board_id":"<parent-board-uuid>","node_id":"parent-image","handle":"left"},
"edge": {
"id":"new-edge",
"data": {
"appearance":{"color":"#AA968B","width":2},
"animation":{"mode":"executing","effect":"flow","direction":"forward","durationMs":1600},
"execution":{"enabled":true}
}
},
"expected_versions": {"<parent-board-uuid>":210,"<child-board-uuid>":1}
}
Use canonical board UUIDs for endpoints and version keys. Eligible real node
kinds are text/script/plain-text/image/plain-image; handles must be one of
top/right/bottom/left. Do not submit projected node IDs, frame IDs as generated
content, or port:* handles. The server exposes a real endpoint through each
containing frame, creating or reusing a port with the matching
direction/nodeId/handle tuple. Existing matching ports retain their IDs,
including migration-created or custom stable IDs. A source chain uses output
ports, and a target chain uses input ports; these are distinct. No unbound port
or writable arbitrary child reference is accepted.
The expected_versions map must contain exactly every board on both paths,
including the endpoint boards and B. For a grandchild-to-parent connection it
contains parent, intermediate child and grandchild; for siblings it contains
both children and their parent. No separate If-Match header is required for this
multi-board operation. Missing participants return 428 version_required;
when a nonempty map has missing/extra participant keys, the response includes
details.required_board_ids. Any stale version returns 412 version_conflict.
All participants require current editor/admin access. Calling a higher ancestor
instead of the lowest common ancestor returns 409 connection_owner_mismatch.
An unrelated/invalid ownership path returns 409 ownership_conflict; resources
outside the caller's scope retain the normal 403/404 behavior.
Only edge.id and optional typed V2 edge.data are accepted. Its appearance,
animation, execution flag and bezier-chain path follow the normal V2 validation.
All knots are already in B's local coordinates; port creation does not move node
geometry or re-layout the edge. An existing edge ID in B returns 409
edge_id_conflict. Graph, port and snapshot capacity failures return 413 without
saving any ports, edges, versions or receipt.
Success is 201 with the coordinator's ETag and:
{
"transaction_id":"<uuid>",
"parent":{"board_id":"<coordinator>","version":211,"document":{}},
"snapshots":[
{"board_id":"<coordinator>","version":211,"document":{}},
{"board_id":"<child-board-uuid>","version":2,"document":{}}
],
"connection":{
"board_id":"<coordinator>",
"edge":{"id":"new-edge","source":"<frame-id>","sourceHandle":"port:<stable-id>","target":"parent-image","targetHandle":"left","data":{}},
"source":{"board_id":"<child-board-uuid>","node_id":"child-text","handle":"right"},
"target":{"board_id":"<parent-board-uuid>","node_id":"parent-image","handle":"left"}
},
"created_ports":[{"board_id":"<coordinator>","frame_id":"<frame-id>","port_id":"<stable-id>"}],
"undo_receipt":{"transaction_id":"<uuid>","valid_for_seconds":604800,"expires_at":"<RFC3339 timestamp>"}
}
The JSON above abbreviates document values and shows a one-level example; nested
connections include every intermediate board in snapshots. The response edge is
the stored owner-board edge, while source/target preserve the logical endpoint
identities. created_ports contains only newly allocated ports; reused ports are
available in the snapshots. Each participating board advances exactly one
version (including endpoint boards whose node content remains unchanged),
consistent with other cross-board commands. Flush pending saves before creating
the connection, then absorb all returned versions/snapshots before resuming
autosave. Retrying an identical body with the same key returns the original 201
response and does not advance versions again. Changing the body, including its
version map, with that key returns 409 idempotency_conflict.
Undo uses the existing POST B/transactions/{transaction_id}/undo endpoint with
a new key and all current participant versions. It restores the previous edge
and port arrays atomically; subsequent incompatible edits cause 412. Existing
ports reused by the command remain after undo. Redo submits the command with
fresh versions and a new key. Ordinary PATCH can continue to edit/remove edges
using already exposed ports, but cannot retarget or directly delete the ports.
Reproducible session API examples
These examples require Bash, curl and jq, an editor/admin login session, and an
explicitly selected board. Set SESSION_TOKEN in memory; use the user session,
never the legacy Codex service token. Run mutations only on a board intended for
V2 testing. Each successful mutation changes versions: reread before the next
operation. Reuse the same key and identical body when retrying an operation.
API=https://api.foundry.entroia.com
PROJECT=your-project
WORKSPACE=your-workspace-id-or-slug
BOARD=your-board-id-or-slug
B="$API/projects/$PROJECT/workspaces/$WORKSPACE/materials/$BOARD/canvas"
api() {
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H 'Accept: application/vnd.foundry.canvas+json;version=2' "$@"
}
# V2-aware read; capabilities report actual deployment and model availability.
api "$B/capabilities"
snapshot=$(api "$B")
version=$(jq -r '.version' <<<"$snapshot")
# Save an unchanged document, explicitly upgrading the envelope to V2.
payload=$(jq '{document:(.document | .schema_version=2)}' <<<"$snapshot")
api -X PATCH "$B" -H 'Content-Type: application/json' \
-H "If-Match: \"$version\"" --data-binary "$payload"
# Create an empty real child with its parent frame, atomically.
version=$(api "$B" | jq -r '.version')
create_key=$(uuidgen)
api -X POST "$B/subgraphs" -H 'Content-Type: application/json' \
-H "If-Match: \"$version\"" -H "Idempotency-Key: $create_key" \
--data '{"frame":{"id":"example-frame","title":"Example","position":{"x":100,"y":100},"width":640,"height":400,"labelMode":"fixed"}}'
# Read-only old-frame preflight. Inspect issues/counts/mappings before executing.
plan=$(api -X POST "$B/subgraphs/migrate" -H 'Content-Type: application/json' \
--data '{"dry_run":true}')
printf '%s\n' "$plan" | jq .
After reviewing that preflight, execute precisely its returned plan with a new UUID key and the unchanged parent version. Any intervening change returns 412; repeat preflight instead of forcing a stale migration.
version=$(api "$B" | jq -r '.version')
migrate_key=$(uuidgen)
payload=$(jq '{dry_run:false,plan_id:.plan_id}' <<<"$plan")
api -X POST "$B/subgraphs/migrate" -H 'Content-Type: application/json' \
-H "If-Match: \"$version\"" -H "Idempotency-Key: $migrate_key" \
--data-binary "$payload"
models=$(api "$B/ai/models")
printf '%s\n' "$models" | jq .
TEXT_MODEL=$(jq -r '[.models[] | select(.available and (.output_modalities | index("text")))][0].id // empty' <<<"$models")
IMAGE_MODEL=$(jq -r '[.models[] | select(.available and (.output_modalities | index("image")))][0].id // empty' <<<"$models")
# Supply existing eligible node IDs from the selected board.
TEXT_NODE=your-text-node
IMAGE_NODE=your-image-node
run_node() {
local node="$1" model="$2" propagate="$3" prompt="$4" version key payload
test -n "$model" || { printf 'No available model for this modality\n' >&2; return 1; }
version=$(api "$B" | jq -r '.version')
key=$(uuidgen)
payload=$(jq -n --arg node "$node" --arg model "$model" --arg prompt "$prompt" \
--argjson propagate "$propagate" \
'{node_id:$node,model_id:$model,prompt:$prompt,parameters:{},propagate:$propagate}')
api -X POST "$B/ai/runs" -H 'Content-Type: application/json' \
-H "If-Match: \"$version\"" -H "Idempotency-Key: $key" --data-binary "$payload"
}
text_run=$(run_node "$TEXT_NODE" "$TEXT_MODEL" false 'Summarize this content')
run_id=$(jq -r '.run_id' <<<"$text_run")
api "$B/ai/runs/$run_id?after_seq=0&limit=100"
api "$B/ai/runs?status=active&include_descendants=true&limit=50"
Wait for a terminal status before starting another run on the same node. Continue
polling from next_seq while has_more is true, then retain the cursor for the
next poll. Image generation and propagation use the same asynchronous contract:
image_run=$(run_node "$IMAGE_NODE" "$IMAGE_MODEL" false 'Generate an image from this reference')
image_run_id=$(jq -r '.run_id' <<<"$image_run")
api "$B/ai/runs/$image_run_id?after_seq=0&limit=100"
# Once succeeded, outputs[].output.src is an authenticated managed image URL.
# Fetch that URL with api and --output to verify the actual image bytes.
# Configure downstream node data.ai and execution edges before propagation.
propagated_run=$(run_node "$TEXT_NODE" "$TEXT_MODEL" true 'Transform and propagate')
api "$B/ai/runs/$(jq -r '.run_id' <<<"$propagated_run")?after_seq=0&limit=100"
The live catalog, rather than the isolated verification IDs above, determines whether each request can run. A 202 response acknowledges a queued job; use its terminal status and authenticated canvas/asset rereads as completion evidence.
Managed image previews
Original asset bytes remain immutable and are used by the ordinary asset URL,
downloads and AI inputs. Authenticated GET .../assets/{id}?variant=preview
returns one display rendition bounded to 1024 pixels on its longest edge. Opaque previews use JPEG quality 82, transparency stays PNG, GIF preserves
the original animation, and JPEG EXIF orientation is applied. Small originals
may be returned unchanged when this is more efficient. No preview URL belongs
in node data.src: persist the original managed URL and optional original
assetId, then select the display variant only in the renderer.
Permissions are checked on every read, including cache hits. Originals are not
replaced. Generation is serialized and the immutable encoded preview cache is
bounded to 32 MiB / 128 entries. Current capabilities advertise assets.previews
and assets.preview_max_edge. No database migration is needed. Asset reads and
Canvas GET release their permission transaction before encoding/writing the
response. Uploads release the initial workspace lock before receiving a large
multipart body and recheck current write permission before committing.
Legacy inline images must be migrated through an explicit editor action: upload unchanged bytes with idempotency keys, then save the same node IDs/geometry with managed asset references and the current canvas version. Opening a canvas does not migrate it or cause an implicit PATCH.
Atomic member drop positions
POST B/subgraphs/{frame}/members additionally accepts optional positions, an object with exactly one {x,y} entry for each node_ids item, expressed in the source board's local coordinates. Both coordinates are required, finite and within ±1,000,000. Capabilities advertise subgraphs.member_positions: true. Omission preserves legacy behavior. Positions, ownership, edge/port conversion, versions and undo receipt commit in one transaction; no preceding coordinate PATCH is needed. Validation failures or stale versions perform zero writes. The positions participate in the idempotency payload digest; retries must keep the entire body and key unchanged. Undo restores the pre-drop snapshot, including original positions.