Skip to content

automation.create / automation.update response contracts — consumer-survey first, then back to the decision inbox (the un-ruled half of the SDK route-contract card) #12206

Description

@os-litant

Important

STATUS (updated 2026-08-30 by the spec PM seat): Step 1 is DONE and the decision is RULED — this card is now the IMPLEMENTATION carrier. Do NOT re-dispatch the survey.
Ruling: Option A adopted (maintainer, 2026-08-26, comment 5421206583, verbatim 「12452 不处理,其他接受」) — both write doors answer the canonicalized (parsed) flow; SDK binds the true return type; three inherited work items ride the same PR. Survey evidence: 5420919914 (accepted 5420937223); re-verified unchanged at 240aad5f (5469341400). Unlock pre-write: 5431687031. The "Step 1 dispatchable now" text below is HISTORICAL.

Filed by the spec PM seat (session_01NDGG54XF5gbTLdQzCtnaVV, R6) executing the disposition the maintainer's ruling prescribed on the parent card. Reader: spec-lane queue — the FIRST half of this card is dispatchable measurement work; the decision itself returns to the inbox only once that reading is attached.

Provenance (ruling, verbatim from the parent card)

automation.create / automation.update: NOT ruled here. They return to the decision inbox as their own card carrying a consumer-survey reading first — does any real consumer depend on the echo shape (the caller's own bytes back)? The candidate direction (answer the registered, parsed flow — canonicalizeStoredFlow's output — so the caller learns what the engine actually stored) is a behaviour change and is decided on that reading, not before. (Maintainer, 2026-08-25, decision-inbox batch 8, verbatim: 「同意」)

Parent: the SDK route-contract card whose ruled half (search + data.clone) landed via its Part of PR. The four-route census and the echo-shape description live there.

Step 1 — the survey (dispatchable now)

Measure whether any real consumer depends on the ECHO shape of automation.create / automation.update (the caller's own bytes back):

  • in-repo call sites of client.automation.create( / .automation.update( and any direct REST callers of those routes (examples/, qa/, dogfood, cloud-connection, services);
  • what each call site does with the RESPONSE (discard / re-read fields / persist);
  • whether any test pins the echo shape;
  • the candidate direction's cost: what canonicalizeStoredFlow's output differs in, on a real flow body (measured, not described).

Deliverable: a reading on THIS card (echo-dependents: zero or the named list; the canonicalized-vs-echo delta on one real body), then this card flips pm:queue → needs-user-decision with the standard four-facet block attached, quoting the reading.

Step 2 — the decision (maintainer's, not dispatchable)

Behaviour change vs declare-the-echo vs defer — decided on the reading, per the ruling. ⛔ Nothing lands on the automation routes before that answer.


Generated by Claude Code

Activity

  1. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 26, 2026
  2. self-assigned this
    on Aug 26, 2026
  3. huangyiirene commented on Aug 26, 2026

    @huangyiirene
    Collaborator

    Claim: PM loop round R7, wave 5 — survey half ONLY (step 1 per the card's own charter; step 2 is the maintainer's, on the reading)
    Session: session_01E5LFCYBJ3q2s6yW6oMLxwy
    Branch: none expected (read-only survey; the canonicalized-vs-echo delta measurement may run code locally but commits nothing)
    Worktree: objectstack-issue-12206 (scratch only if needed)
    Domain: domain:spec
    File surface: NONE — ⛔ nothing lands on the automation routes before the ruling, per the card. Any code change is a breach.
    Container & model: S, mode:subagent, model: opus — measurement work; the eventual implementing dispatch (if the ruling picks the behaviour change) is the clause-② one
    Clause-②: no (this dispatch changes nothing)
    Serial constraints cleared: zero file surface; in-flight #12248 (contracts/data-engine.ts) untouched; landing-watch PRs untouched. H17: no hit.


    Generated by Claude Code

  4. huangyiirene commented on Aug 26, 2026

    @huangyiirene
    Collaborator

    os-dev-report

    Survey half only (step 1 of this card's own charter). No branch, no PR, no code change — the card's fence held. Measured at b6c96bcea5d4352c5a63b4aa9a8d6809b9ce15cf (origin/main, objectstack) and 7b6f7f889 (objectui), in a throwaway detached worktree that has since been removed; git status was clean at teardown and nothing was committed anywhere.

    Session session_01E5LFCYBJ3q2s6yW6oMLxwy.


    0. Body completeness + one correction to the parent card's premise

    The card body is complete and self-carrying: the maintainer's 2026-08-25 ruling is quoted verbatim, the fence is stated, and step 1's four measurement asks are enumerable. Parent read: #11924 (closed 2026-08-25, ruled half landed) — its four-route census and echo-shape description are as the card summarises them.

    One sub-premise of the parent card is not accurate at head, and it changes what the three-way fork actually costs. #11924 says of these two routes: "there is no type anywhere to bind to … They are a missing contract, not a missing annotation."

    There is a type, it is published, and it already says Flow:

    what where
    CreateFlowResponseSchema = BaseResponseSchema.extend({ data: FlowSchema }) — "The created flow definition" packages/spec/src/api/automation-api.zod.ts:138-145
    UpdateFlowResponseSchema = BaseResponseSchema.extend({ data: FlowSchema }) — "The updated flow definition" packages/spec/src/api/automation-api.zod.ts:163-171
    both re-exported from @objectstack/spec/api packages/spec/src/api/index.ts:80 (export * from './automation-api.zod')
    both in the published surface ledgers packages/spec/json-schema.manifest/api.json:86,398; packages/spec/authorable-surface/api.json:376-379,1669-1672; packages/spec/export-origins/api.json:196-198,948-950
    packages/client can reach them its deps are @objectstack/core + @objectstack/spec, and it already imports @objectstack/spec/automation (packages/client/src/index.ts:120)

    So what is missing is not a type — it is conformance. The declaration promises data: Flow; the route answers the caller's unparsed bytes. The declaration's only consumers anywhere are its own unit tests (packages/spec/src/api/automation-api.zod.test.ts:190, :225), which parse handwritten literals and have never seen a route response. That is precisely the #3877 hazard the parent card quotes ("⛔ DO NOT FILL A ROW THAT HAS NO CONFORMANCE COVERAGE") — already realised, in the direction of a declaration that outruns the implementation.

    premise_still_valid is therefore true for this card's own premise (the survey question is real and answerable) and false for the parent's "nothing to bind to" framing. Flagged rather than acted on: the fence forbids touching these routes, and which way the mismatch gets closed is the pending decision.


    1. Population + control — in-repo call sites

    Method: grep -rn over *.ts *.tsx *.mts *.js *.mjs *.md *.mdx across the whole monorepo, node_modules and CHANGELOG.md excluded, for automation.create( / automation.update(; plus a separate sweep for direct REST callers (any fetch/axios/curl/POST/PUT line naming the automation write routes) over examples/, apps/, packages/qa/ (incl. dogfood/), packages/cloud-connection/, packages/services/, packages/cli/, packages/mcp/, packages/console/, and content/docs/.

    Subject:

    client method total call sites in tests non-test direct REST callers
    client.automation.create( 1 1 0 0
    client.automation.update( 1 1 0 0

    Control — the same sweep, same pattern, neighbouring methods. The zeros above are about the subject, not about the sweep: the identical grep finds plenty elsewhere, including on the sibling doors of the very same client.automation object.

    method total in tests non-test non-test sites (sample)
    automation.trigger( 13 5 8 content/docs/api/client-sdk.mdx:428, packages/client/README.md:277, runtime docblocks
    automation.delete( 1 1 0 —
    automation.get( 1 1 0 —
    automation.list( 1 1 0 —
    automation.toggle( 1 1 0 —
    data.create( 13 4 9 packages/client-react/src/data-hooks.tsx:287, packages/cli/src/commands/data/create.ts:85, content/docs/api/client-sdk.mdx:100,254,616, content/docs/api/error-catalog.mdx:745, content/docs/api/data-flow.mdx:204
    data.update( 10 7 3 —
    data.clone( 2 2 0 —

    Second control, at the HTTP layer rather than the SDK layer: the automation execution and read doors are called from real product code all over (objectui alone has ~15 fetch sites against /trigger, /resume, /runs, /actions, /connectors, /_status). The two definition-write doors are called from none.

    How flow definitions actually get written in production, since it is not through these routes:

    1. Authored in an app package — defineFlow(...) in examples/app-crm/src/flows/convert-lead.flow.ts, examples/app-todo/src/flows/task.flow.ts, examples/app-showcase/src/automation/flows/*.flow.ts, loaded by the metadata loader. No HTTP at all.
    2. Studio flow designer — client.save('flow', name, draft, { mode: 'draft', packageId }) at objectui packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx:3315,3378,3408. That is the metadata door (PUT /meta/flow/:name), not POST /automation.

    So the two routes under decision are, at head, a published SDK surface with no in-repo consumer and no production writer.


    2. Per call site — what the RESPONSE is used for

    # file:line call what happens to the response
    1 packages/client/src/client.test.ts:1221 await client.automation.create('new_flow', { label: 'New' }) discarded. Not assigned, not asserted. The test's only assertions are on the request: expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1/automation', expect.objectContaining({ method: 'POST' })). The mocked data: { name: 'new_flow' } is a stub the test never reads back.
    2 packages/client/src/client.test.ts:1234 await client.automation.update('my_flow', { label: 'Updated' }) discarded, identically. Asserts URL + method: 'PUT' only; the mocked data: { name: 'my_flow', label: 'Updated' } is never read.

    Contrast, from the same describe block, to show these tests do read responses when a shape is being pinned: automation.list asserts result.flows (:1198), automation.get asserts result.name (:1211), automation.delete asserts result.deleted (:1252), automation.toggle asserts result.enabled (:1265).

    Fields re-read: 0. Persisted: 0. Discarded: 2 of 2.

    ⚠️ A fidelity note on both call sites, because it bears on how much these two tests can be treated as evidence at all: their bodies are not registrable. Measured against a real AutomationEngine:

    THROW  update body { label: 'Updated' }
           ZodError: name — expected string, received undefined;
                     type — expected one of "autolaunched"|"record_change"|"schedule"|"screen"|"api"
    THROW  create body { name: 'new_flow', label: 'New' }
           ZodError: type — expected one of "autolaunched"|…; nodes — expected array
    

    Against a real server both calls are 400, not 200. The two tests pass only because fetch is mocked. They pin the request URL and verb — nothing about the response contract, in either direction.


    3. Tests pinning the ECHO shape

    Systematic search: every *.test.ts under packages/runtime/src/domains, packages/qa (incl. dogfood/), packages/client/src, packages/rest/src, for (a) assertions comparing a response body to the request object (toEqual(body|definition|WELL_FORMED|DEFINITION|flow)), (b) any .data/success assertion in the automation write tests, (c) the word "echo" near flow/definition.

    Result: exactly one, and it survives either direction.

    packages/runtime/src/domains/automation-register-error-class.test.ts:352-353

    // The definition is echoed back unchanged, as it always was.
    expect(result.response?.body?.data ?? result.response?.body).toMatchObject({ name: 'welcome_flow' });

    toMatchObject({ name }) — and name is required by FlowSchema and preserved by canonicalization (measured below), so the assertion passes under the canonicalized direction too. What would go stale is the comment, not the test.

    Everything else that drives these two doors asserts status/envelope only, never the body's content:

    file:line what it asserts
    automation-put-post-error-parity.test.ts:292,298 status 200 + body.success === true on both doors
    automation-body-validation.test.ts:161-165 PUT accepts bare definition and {definition} envelope — asserts status 200 and the registerFlow spy arguments, not the response
    automation-write-capability-gate.test.ts:193,293 the manage_metadata gate; the only body assertion in the file is data.deleted on DELETE (:307)
    automation-toggle-deny-message.test.ts:130,135 drives POST /automation and PUT /automation/:name purely as denial probes
    packages/qa/dogfood/test/automation-toggle-tenant-scope.dogfood.test.ts:213-220 status 403 + error.code === 'PERMISSION_DENIED'
    packages/client/src/return-type-precision.test.ts pins automation.getFlow → FlowParsed, execute, getRun, listActions, runs.list. create/update appear nowhere in it.

    Docs: zero. content/docs/automation/flows.mdx's route table (:1370-1373) lists trigger / resume / runs — POST /api/v1/automation and PUT /api/v1/automation/:name are not documented anywhere under content/docs/, so no published example shows a caller reading these responses.

    Echo-dependents: ZERO. No call site reads the response, no test pins its content, no doc shows it.


    4. The candidate direction's cost — MEASURED

    Route sites at head, unchanged from the parent card's description:

    • POST /automation → deps.success(body) — packages/runtime/src/domains/automation.ts:1137 (the caller's own bytes; the SDK sends { name, ...definition })
    • PUT /automation/:name → deps.success(definition) — packages/runtime/src/domains/automation.ts:1855, where definition = body.definition ?? body (the SDK sends { definition })

    The engine already computes the candidate answer and throws it away. AutomationEngine.registerFlow opens with const { parsed } = this.canonicalizeStoredFlow(name, definition); (packages/services/service-automation/src/engine.ts:2626-2629) and returns void (IAutomationService.registerFlow(name, definition: unknown): void, packages/spec/src/contracts/automation-service.ts:389). Both routes call it and then echo. So the candidate direction adds no computation — it plumbs a value that is already produced on every successful request.

    Corollary worth stating explicitly, because it removes one whole class of risk from the fork: the candidate direction adds no new refusal surface. canonicalizeStoredFlow runs before the echo, so any body that reaches deps.success(...) today has already canonicalized successfully. FlowSchema is strict (#4001) — measured, one unknown key is a hard refusal:

    THROW  well-formed + one unknown key `mystery`
           ZodError: unrecognized_keys — "Unrecognized key(s) on this flow: `mystery`…"
    

    — and that is today's 400 (flowDefinitionRefusal, #8055/#8123), not a new one.

    4a. Delta on real flow bodies

    Method, and one correction to a first attempt that would have reported a false zero. defineFlow is FlowSchema.parse (packages/spec/src/automation/flow.zod.ts:887-889), so feeding a defineFlow(...) export into canonicalizeStoredFlow measures a parse against an already-parsed body and yields a tautological 0 differences — which is what my first run printed. Discarded. The bodies below are the raw authored literals (the defineFlow import replaced by an identity stub, verified with a grep -c on both the removed import and the injected stub before running; and app-todo's four flows are plain Flow-typed object literals with no defineFlow at all). That is what a real HTTP caller POSTs.

    Ran on @objectstack/service-automation built from b6c96bc (pnpm --filter '@objectstack/service-automation...' build, under scripts/pm/os-verify-lock.sh — VERDICT command-exit 0 · held the lock 205s).

    real flow body parsed vs echo storable vs echo bytes echo → parsed notices / conflicts
    examples/app-crm/.../convert-lead.flow.ts (crm_convert_lead_wizard, 8 nodes / 8 edges) 12 diffs — 8 defaults materialized, 3 key-order, 1 type-change 1 diff 3031 → 3196 (+5.4%) [] / []
    examples/app-showcase/.../dynamic-approval.flow.ts (5/5) 13 diffs — 12 defaults, 1 key-order 0 diffs 1372 → 1574 (+14.7%) [] / []
    examples/app-todo/.../task_reminder (5/4) 7 diffs — 6 defaults, 1 key-order 0 diffs 1416 → 1517 (+7.1%) [] / []
    examples/app-todo/.../overdue_escalation (6/5) 8 diffs — 7 defaults, 1 key-order 0 diffs 1760 → 1879 (+6.8%) [] / []
    examples/app-todo/.../task_completion (6/6) 13 diffs — 8 defaults, 3 key-order, 2 type-change 2 diffs 2250 → 2439 (+8.4%) [] / []
    examples/app-todo/.../quick_add_task (5/4) 7 diffs — 7 defaults, 0 key-order 0 diffs 1975 → 2091 (+5.9%) [] / []

    Exactly what differs — three kinds, no more:

    1. Defaults materialized (every body, 6–12 occurrences each) — top level version: 1, status: "draft", runAs: "user"; per edge type: "default", isDefault: false. Additive only; no authored value is ever changed or dropped. Sample:
      [KEY-ADDED] $.version              echo=—  new=1
      [KEY-ADDED] $.runAs                echo=—  new="user"
      [KEY-ADDED] $.status               echo=—  new="draft"
      [KEY-ADDED] $.edges[0].type        echo=—  new="default"
      [KEY-ADDED] $.edges[0].isDefault   echo=—  new=false
      
    2. Key order (0–3 sites per body) — the parse re-emits keys in schema order, e.g. …,type,status,runAs,successMessage,… → …,successMessage,errorMessage,status,type,…,runAs,…. Invisible to any JSON object consumer; visible to a byte-for-byte or snapshot comparison.
    3. One real TYPE change — the only shape-breaking difference in the whole measurement. A string edge condition becomes the lowered CEL envelope:
      [TYPE-CHANGED] $.edges[2].condition
           echo = "lead_record.status == 'converted'"
           new  = {"dialect":"cel","source":"lead_record.status == 'converted'"}
      
      3 occurrences across 6 bodies (convert-lead x1, task_completion x2). This is the one place a consumer doing typeof edge.condition === 'string' would break — and per sections 1-3 there is no such consumer in either repo.

    Conversions were a no-op on every modern body: notices=[] conflicts=[] for all six. The legacy-dialect machinery inside canonicalizeStoredFlow costs nothing on flows authored against today's shape.

    Round-trip is stable: canonicalize(parsed).parsed deep-equals parsed — answering the canonicalized shape does not create a body that canonicalizes differently on the next write.

    4b. parsed vs storable — the candidate direction names parsed, and the platform persists storable

    The rightmost columns above are the finding: storable differs from the echo in 0-2 places, parsed in 7-13. canonicalizeStoredFlow returns both, deliberately (packages/services/service-automation/src/engine.ts:2536-2548): "Schema defaults (version, runAs, per-edge type / isDefault) are excluded on purpose — writing values the author never wrote would freeze every migrated row on today's defaults while untouched rows follow tomorrow's."

    And that choice is already made for the write path everyone actually uses. The metadata save door — PUT /meta/flow/:name, the one the Studio designer calls — canonicalizes flow bodies and "Persists storable, never the parsed shape — schema defaults are deliberately excluded (ADR-0087)" (packages/metadata-protocol/src/protocol.ts:13672-13683).

    So "answer FlowParsed" and "answer what the engine stored" are, on this platform, two different shapes.

    4c. The write/read asymmetry the routes have today

    GET /automation/:name serves automationService.getFlow(name) (packages/runtime/src/domains/automation.ts:1817-1823), and AutomationEngine.getFlow returns this.flows.get(name) (engine.ts:2828-2830) — the map registerFlow fills with parsed. The client declares that door as Promise<FlowParsed> and it is compile-time pinned (packages/client/src/return-type-precision.test.ts:103,121).

    Measured round-trip, same engine, same flow, POST-then-GET:

    POST echo bytes      : 3031
    subsequent GET bytes : 3196
    echo === GET ?       : false
    top-level keys only in GET : [ 'version' ]
    edges[2].condition  echo = "lead_record.status == 'converted'"
    edges[2].condition  GET  = {"dialect":"cel","source":"lead_record.status == 'converted'"}
    

    A caller who writes a flow and immediately reads it back gets different bytes from the same resource, and only the read half is declared. The measured delta in 4a is that gap; the candidate direction closes it by making the write doors answer what the read door already answers.

    4d. Two adjacent contract facts, recorded because the ruling inherits them

    • PUT's echo can omit name. deps.success(definition) where definition = body.definition ?? body, and the SDK sends { definition } — so where POST's echo carries name, PUT's carries whatever the definition object had. The canonicalized answer would always carry it.
    • The declared PUT request contract does not match the engine. UpdateFlowRequestSchema declares definition: FlowSchema.partial() (packages/spec/src/api/automation-api.zod.ts:157-160), but registerFlow → canonicalizeStoredFlow → FlowSchema.parse requires a complete flow — measured above: { label: 'Updated' } throws on name and type. A partial update is declared and not implemented. Recorded here rather than filed separately: it is the same routes under the same fence, and closing it is part of whichever direction wins.

    5. objectui leg — MEASURED, zero

    objectui @ 7b6f7f889, swept for automation.create( / automation.update( / api/v1/automation / '/automation' across *.ts *.tsx *.svelte *.vue *.js, node_modules excluded.

    Consumers of the definition-write doors: 0. Every one of the ~24 automation hits is on the execution, read or descriptor surface:

    surface sites
    POST /api/v1/automation/{name}/trigger packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:546, packages/app-shell/src/views/RecordDetailView.tsx:845 (+ tests)
    POST /api/v1/automation/{flow}/runs/{runId}/resume packages/app-shell/src/views/FlowRunner.tsx:159 (+ 4 tests)
    GET /api/v1/automation/actions packages/app-shell/src/views/metadata-admin/previews/useFlowNodePalette.ts:87 (+ tests)
    GET /api/v1/automation/connectors packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx:428,465
    GET /api/v1/automation/_status packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx:3258, packages/app-shell/src/views/setup/packagedFlows.ts:14

    And the load-bearing negative: the Studio flow designer — the one place in the product where a human authors and saves a flow — does not use these routes. It saves through the metadata door: client.save('flow', name, draft, { mode: 'draft', packageId }) at StudioDesignSurface.tsx:3315 (skeleton create), :3378 and :3408 (updates). Whatever the ruling does to POST /automation / PUT /automation/:name, the Studio designer is not affected.


    6. cloud / hotcrm — DECLARED UNMEASURED

    Neither repo is attached to this container (/home/user holds objectstack and objectui only). Not guessed. A seat with access should run, per repo:

    # 1. SDK call sites
    grep -rn "automation\.create(\|automation\.update(" \
      --include=*.ts --include=*.tsx --include=*.mts --include=*.js --include=*.mjs --include=*.mdx . \
      | grep -v node_modules
    
    # 2. direct REST callers of the two definition-write doors
    grep -rnE "(POST|PUT)[^\"]{0,40}/automation" \
      --include=*.ts --include=*.tsx --include=*.py --include=*.go --include=*.http --include=*.sh . \
      | grep -v node_modules | grep -vE "trigger|/runs|/toggle|/actions|/connectors|_status|resume|execute|hooks"
    
    # 3. anything reading a response field back off those calls
    grep -rn "api/v1/automation" -r . | grep -v node_modules
    
    # 4. control (proves the sweep finds things in THAT repo)
    grep -rn "\.data\.create(\|automation\.trigger(" --include=*.ts --include=*.tsx . | grep -v node_modules

    The in-repo evidence makes a non-zero there unlikely but does not settle it: these routes have no documentation anywhere under content/docs/, which is the surface an external consumer would have had to learn them from.


    7. The fork, stated neutrally with the evidence each direction leans on

    A — behaviour change: answer the canonicalized flow.
    Leans on: echo-dependents measured at zero across both repos (sections 1-3, with a control proving the sweep finds real call sites); the value is already computed and discarded on every successful request (engine.ts:2629); no new refusal surface — canonicalization already runs before the echo, so every 200 today already canonicalized; GET /automation/:name on the same resource already answers this shape and is already declared and compile-time pinned as FlowParsed (4c), so this makes the resource self-consistent; a published CreateFlowResponseSchema / UpdateFlowResponseSchema already promises data: Flow (section 0) and would become true instead of aspirational; the one test whose comment mentions the echo still passes (section 3). Costs: it is a behaviour change on a published SDK surface — 7-13 field differences and +5%-15% bytes per body, including one genuine type change (edge.condition string → {dialect, source}) that an undiscovered external consumer could be reading; and it forces the parsed-vs-storable question (4b), because answering FlowParsed hands the caller a shape that differs from what the platform persists.

    B — declare the echo.
    Leans on: no behaviour change, so no external consumer can break; the echo is what these routes have always done, and #3877's standing ruling is that a response schema is a product decision, which "describe what it does today" honours minimally. Costs: the shape being declared is the caller's own unvalidated bytes — as #11924 puts it, naming Flow here "would be a claim about the REQUEST that no validation backs", and the bytes are not Flow (they are pre-parse input, sometimes without name on PUT — 4d); it would have to be declared as two different shapes for POST and PUT; it entrenches the write/read asymmetry on one resource (4c); and it contradicts the CreateFlowResponseSchema/UpdateFlowResponseSchema already published in packages/spec (section 0), which would then need retiring or rewriting.

    C — defer.
    Leans on: measured consumer count is zero in both directions, so nothing is currently broken for anyone, and the routes have no production writer at all — the Studio designer uses the metadata door (section 5), apps use the loader (section 1). Costs: Promise<any> stays on two published SDK methods; the spec-side declaration keeps promising a shape the route does not produce (section 0); the write/read asymmetry and the undelivered partial-update contract (4d) stay unrecorded anywhere but this card.

    No recommendation — step 2 is the maintainer's, per the ruling.


    Reproduction

    Measurements ran in a detached worktree at b6c96bc, since removed; scratch scripts are gone from the tree and nothing was committed. To reproduce: build @objectstack/service-automation, take a raw authored flow literal (an examples/app-todo/src/flows/task.flow.ts export is already one; for defineFlow exports replace the @objectstack/spec import with an identity stub first, or the measurement is a tautological zero), then diff new AutomationEngine(logger).canonicalizeStoredFlow(name, body).parsed against the body, and separately registerFlow + getFlow against the same body.


    Generated by Claude Code


    Generated by Claude Code

  5. huangyiirene commented on Aug 26, 2026

    @huangyiirene
    Collaborator

    Survey seat claim + two rendering corrections to the reading above.

    Claim (survey half only). Session session_01E5LFCYBJ3q2s6yW6oMLxwy, dispatched by the domain:spec PM seat, R7 wave 5. Survey-only — no branch expected, none created, nothing committed. Measurement ran in a detached worktree at b6c96bc which has since been removed; git diff HEAD was empty at teardown. The card's fence (nothing lands on the automation routes before the ruling) held: zero files touched in any repo. This confirms and does not replace the earlier claim comment on this card, which carries the same session id.

    Correction 1 — sanitizer ate two type parameters. GitHub's body sanitizer strips short angle-bracket fragments even inside backticks, so in section 4c and in fork option C the generic Promise should read "Promise of FlowParsed" (the client's declared return for client.automation.getFlow, pinned in packages/client/src/return-type-precision.test.ts:103,121), and in option C it is "Promise of any" that stays on client.automation.create / .update. The reading's substance is unchanged; only the two type arguments were dropped in rendering. Recording it rather than editing the comment, since an edit would strip them again.

    Correction 2 — the section 6 reproduction snippet. The second grep in the cloud/hotcrm block got its quoting mangled on the way through. The intent, stated in prose so it cannot be mangled again: sweep for lines that contain either the verb POST or PUT together with the path segment /automation not followed by any of trigger, /runs, /toggle, /actions, /connectors, _status, resume, execute, hooks — i.e. the two definition-write doors only, with the execution and descriptor surface excluded. The other three commands in that block are correct as printed.


    Generated by Claude Code


    Generated by Claude Code

  6. huangyiirene commented on Aug 26, 2026

    @huangyiirene
    Collaborator

    ACCEPT (survey half) — reviewer of record: spec seat R7, session_01E5LFCYBJ3q2s6yW6oMLxwy. Zero-trace verified (no branch, no PR, fence held); reading read back on the card. Review notes:

    1. The zero is CONTROLLED (neighbouring methods census: data.create 9 non-test sites, automation.trigger 8) and the one echo-mentioning test survives either direction — the reading is decision-grade.
    2. The candidate-direction cost is measured, not described, and the first tautological run (defineFlow IS the parse) was discarded and re-instrumented on raw authored literals — instrument-discipline save Add Changesets and GitHub Actions automation #4 this shift.
    3. Parent-premise correction accepted as load-bearing: CreateFlowResponseSchema/UpdateFlowResponseSchema already exist and promise data: Flow — option B now costs a retire-or-rewrite of published declarations, not a mere annotation. Recorded here; Four client SDK routes answer a shape no published contract declares — automation.create / automation.update / search / data.clone #11924 is closed and this card is the live carrier.
    4. On the dev's closing question: the three embedded defects (declaration-outruns-route, UpdateFlowRequestSchema.partial() declared-not-implemented, the two 400-body SDK unit tests) stay IN the reading — correct call; filing them as standalone cards an hour before a ruling on the same two routes would fragment the decision. Whichever direction the ruling picks, the implementing card inherits them as named work items; if the ruling defers (C), this seat files them as findings then.

    State conversion follows: pm:queue-family → needs-user-decision with the four-facet block quoting this reading, per the card's own charter.


    Generated by Claude Code

  7. huangyiirene commented on Aug 26, 2026

    @huangyiirene
    Collaborator

    os-decision-facets

    一句话问题:POST /automation 与 PUT /automation/:name 今天把调用者的原始字节原样回显(未经校验),而 spec 里已发布的响应契约承诺的是 data: Flow —— 调查读数已齐,请裁:这两个门应答什么?

    选项:

    • A — 行为变更,应答规范化后的 flow(candidate 方向,裁决预留的那条)。
    • B — 照实声明回显(不改行为,把「原样回显未校验字节」写成契约 —— 需退役/改写已发布的两个响应 schema)。
    • C — 押后(两个已发布 SDK 方法保持 Promise-any,声明与路由继续背离)。

    读数要点(全文在上方 os-dev-report):echo 依赖者双仓归零(带对照:邻近方法 8-9 个真实站点);这两个路由无任何生产写手(Studio 走 metadata 门);规范化值每次成功请求已经计算完并被丢弃(零边际计算);全部 6 个真实 flow 体实测差异仅三类 —— 默认值补齐(纯增)、键序、一处真类型变化(edge.condition string → {dialect,source},全库零消费者);同一资源写读不对称已实测(POST 回显 ≠ 随后 GET,GET 已声明并编译期 pin 为 FlowParsed)。

    ① 实际业务需求:零消费者 + 零生产写手 ⇒ 没有任何存量用户会被 A 破坏;而已发布的响应 schema 在对消费者撒谎(承诺 Flow、实答原始字节)。
    ② 项目长远合理性:declared=enforced —— A 让已发布的声明变真;B 是反向工程:把「未校验回显」抬进契约,还得改写两个已发布 schema 去承诺一个非 Flow 的形状,并固化同资源写读不对称。
    ③ 防 AI 写代码犯错:AI 消费者今天写后即读会拿到两种字节;A 统一为 GET 已 pin 的同一形状,响应可信可解析。这正是「契约收紧优于消费端宽容」。
    ④ 创业阶段不扩散:A 是把已算好的值接到出口(最小真实 diff);C 留着假声明与 Promise-any 继续发酵。

    推荐 A,且应答 parsed 形状(与同资源 GET 已声明的 FlowParsed 一致 —— 一个资源一个形状;若答 storable 会给调用者引入第三种可见形状)。回退 B;不推荐 C(假声明留在已发布面)。
    裁 A 时实施卡将一并继承三件已具名工作:① 响应 schema 从此符合;② UpdateFlowRequestSchema.partial() 声明了引擎不接受的部分更新 —— 建议同 PR 收紧为完整定义(要真做 partial 是另一张 feature 卡);③ 两个 SDK 单测的请求体对真实服务器是 400,需修为可注册体。
    置信缺口:cloud / hotcrm 未测(命令已附卡);「场外外部消费者」在任何仓位不可见 —— A 的唯一残余风险即那处 string→object 类型变化。

    回一个字即可:「A」= A + parsed + 三件继承工作同 PR;「B」/「C」照述;其它意见照述。


    Generated by Claude Code

  8. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 26, 2026
  9. removed their assignment
    on Aug 26, 2026
  10. os-steve commented on Aug 26, 2026

    @os-steve
    Collaborator

    Maintainer ruling recorded — Option A adopted: both write doors answer the canonicalized (parsed) flow

    Provenance: maintainer, 2026-08-26, live PM chat (decision-inbox batch 5, skills seat session session_01JANH3y7qe3MD8aLaLXci8N), verbatim: 「12452 不处理,其他接受」 — accepting this card's presented recommendation A, as framed by the four-facet block above (A + the parsed shape + the three inherited work items in the same PR).

    What this rules:

    1. POST /automation and PUT /automation/:name answer the canonicalized flow in the parsed shape — the same shape GET /automation/:name already answers and the client already pins, making the resource self-consistent. The value is already computed on every successful request; this plumbs it to the exit.
    2. client.automation.create / .update bind the single true return type (FlowParsed), replacing the any declarations.
    3. Three inherited work items ride the same PR: ① the published CreateFlowResponseSchema / UpdateFlowResponseSchema become conformant instead of aspirational; ② UpdateFlowRequestSchema's .partial() declaration is tightened to the complete definition the engine actually requires (a real partial-update capability would be its own feature card); ③ the two SDK unit tests whose bodies are 400 against a real server are fixed to registrable bodies.
    4. Clause-② declared: a behaviour change on a published SDK surface — the PR carries a changeset with a migration note; the survey's one residual risk (the edge.condition string → {dialect, source} type change, zero measured consumers) is named in it.

    State: needs-user-decision → pm:queue in the same stroke — the domain:spec lane dispatches, with the survey report on this card as the implementing evidence base.


    Generated by Claude Code

  11. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 26, 2026
  12. 21 remaining items

  13. os-project-manager commented on Aug 30, 2026

    @os-project-manager
    Collaborator

    DEV CLAIM (implementation, under PM claim 5469352170) — dev seat dispatched by session_01KX8wnyjStaZcuMyAMNsy3N · branch claude/issue-12206-automation-write-doors-parsed (reused per pre-write 5431687031; verified zero unique commits over origin/main, resetting onto current origin/main).

    Scope: ruling 5421206583 (Option A) exactly — both write doors answer the canonicalized parsed flow; SDK binds FlowParsed; inherited items ①②③; changeset with migration note naming the edge.condition string-to-object residual risk. #12452 untouched per the ruling. PR parks DRAFT with needs:contract-review on PR and card (clause-②).


    Generated by Claude Code

  14. added
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Aug 30, 2026
  15. os-project-manager commented on Aug 30, 2026

    @os-project-manager
    Collaborator

    os-dev-report

    {
      "card": 12206,
      "status": "done",
      "branch": "claude/issue-12206-automation-write-doors-parsed",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/13525",
      "ruled_items": {
        "1": "done — POST /automation and PUT /automation/:name answer the canonicalized parsed flow: IAutomationService.registerFlow returns FlowParsed (was void), AutomationEngine.registerFlow returns the stored parsed object, both route exits relay it (deps.success(registered)); PUT answer always carries name",
        "2": "done — client.automation.create/.update bind Promise of FlowParsed; the falsified deliberate-any comments removed; both exported-any-returns.json entries deleted (shrink-only graduation, gate green: 317 callables reached, 40 ledgered sites still open); compile pins added in return-type-precision.test.ts",
        "3": "done — Create/UpdateFlowResponseSchema now conformant: new e2e (packages/client/src/automation-write-door-parsed-answer.test.ts — real AutomationEngine + real HttpDispatcher + real ObjectStackClient, in-process socket) parses both schemas against the real wire bodies; describe/TSDoc updated; automation-api.mdx regenerated",
        "4": "done — UpdateFlowRequestSchema.definition drops .partial(), requires the complete definition the engine requires; no partial-update capability built (future feature card per the ruling)",
        "5": "done — both SDK unit tests use registrable bodies, each pinned registrable by a real FlowSchema.parse of the exact wire body the SDK sends",
        "6": "done — .changeset/automation-write-doors-parsed.md (minor: spec, runtime, client, service-automation) with migration note naming the survey's residual risk verbatim: the edge.condition string to dialect-plus-source envelope type change, zero measured consumers, cloud repo declared unmeasured"
      },
      "gates": "dispatch-gates.mjs --repo objectstack-ai/objectstack, no hand-fed paths, derived at 7e4d9805 then re-derived at f734a5fa: 28 + 17 delta families, all green, incl. check:docs (230 generated files in sync), check:generated, check:authorable-surface, check:cross-package-test-inputs, check:test-source-alias, check:engine-double-contract, check:dual-build-cjs-loads (102 require entry points across 66 packages load), check:skill-examples (260 prose examples type-check), check:type-check-coverage, check:type-check-debt (29 ledger entries re-measured, none above its recorded number), check:exported-any-returns. Typecheck green: spec, client, runtime (single AND-chain, VERDICT command-exit 0). service-automation has no typecheck script (pre-existing ledger posture) — not measured, not claimed; runtime tsc excludes test files (pre-existing), so the edited runtime tests are vitest-executed, not tsc-measured. Full test union at f734a5fa: spec 443 files / 11812 tests, client 31/407, runtime 201/2973, service-automation 91/1092 — per-suite exits captured before any pipe, all 0.",
      "ablation": "All directions predicted before running; every mutation and restore proven on disk. (a) OLD partial-update pin vs new schema: red as predicted (1 failed / 42 passed), then replaced by reject-partial + accept-complete, green. (b) OLD echo pin (register-error-class) vs new answer: red as predicted (data undefined from its void double), double made faithful (returns parsed), pin asserts materialized version 1, green. (c) Engine leg: return-parsed line removed (grep 1 to 0, marker 0 to 1), new engine pin + both client e2e cases red (exit 1 each); restore via git checkout HEAD, git hash-object equals HEAD blob 1a3bf67f, reruns green. (d) Route leg: both deps.success(registered) reverted to the echo (grep 2 to 0, marker 0 to 2), runtime pin red (exit 1); restore hash-verified 0ff5f6bd equals HEAD, rerun green. No rebuild owed by construction: every suite in the legs resolves the mutated file as source (in-package src imports; client resolves service-automation through its vitest src alias); the runtime-dist seam was deliberately not used as a leg. Trap-guarded script with absolute paths.",
      "parked": true,
      "deviations": [
        "Commit 279d04dd's trailer carries the harness default co-author line naming the model; the dispatch forbids model identifiers in pushed artifacts. Caught after push; history is append-only without force-push (banned), so it stands in branch history. Later commits drop it; PR body asks the merger for a clean squash message.",
        "check:dual-build-cjs-loads and check:skill-examples initially exited PREREQUISITE NOT MET (packages outside my closure unbuilt) — recorded as NOT MEASURED, then a full turbo build was run and both re-measured green; not reds at any point.",
        "check:docs-single-h exit 254 was my own script-name typo (MODULE_NOT_FOUND class, not a measurement); re-run as check:docs-single-h1, green."
      ],
      "blockers": [],
      "notes": "Scope fence held: no edit on the automation routes beyond the six ruled points; the number-12452 surface untouched and its card remains open; PUT's pre-existing two-dialect body unwrap kept as-is. create/update request parameter types deliberately untouched (ruling names the return type; request accept-set narrowing is a separate decision). Branch reused per pre-write 5431687031: verified zero unique commits over origin/main, fast-forwarded d7b3963c to 4ca7ccf2 as the write probe. Label writes went read-union-write-readback (additive REST endpoint not available to this seat): card set is pm:dispatched + domain:spec + needs:contract-review, read back intact; PR carries needs:contract-review plus a labeler bot's documentation/tests/tooling. PR parked DRAFT, never readied; review chain owns enqueue. MCP GitHub calls this run: 8. Content-liveness note for the PM: the card body still advertises step 1 as dispatchable; this comment does not edit it."
    }
    

    Generated by Claude Code


    Generated by Claude Code

  16. claude commented on Aug 30, 2026

    @claude
    Contributor

    PM record — trailer hygiene closed on PR #13525; card fully processed for this shift. (session_01KX8wnyjStaZcuMyAMNsy3N)

    Commit 279d04dd's model-named co-author trailer could not stand (this repo lands merge commits, so branch messages reach main history). The dispatched dev did not complete the authorized rewrite within a reasonable window, so this seat executed it directly: filter-branch --msg-filter over the three branch commits, trees byte-identical (68140f55/fdc01832/54eda354 unchanged), model-name grep = 0, Claude-Session lines kept, pushed --force-with-lease (f734a5fa → 8d2d0c43). Parking verified intact after the push on both carriers. CI re-runs on the new head; it is a message-only rewrite of a tree CI already passed at f734a5fa.

    The ACCEPT (5469920495-based) stands. Enqueue belongs to the review chain / maintainer.


    Generated by Claude Code

  17. removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

domain:specDomain lane — seat card indexed by label:pm:seat

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions