Skip to content

fix(spec)!: refuse a start-node filter beside timeRelative and a schedule the readers cannot use - #31376

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-52y8an
Nov 8, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-52y8an

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Refs #31224

Clause-②: yes (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — skills/** and content/docs/** teach timeRelative.filter inside the descriptor and typed { type: 'cron', expression } schedules, which this accepts. The generated skills/objectstack-automation/references/_index.md gains the source files the flow closure now reaches (system/job.zod.ts and its imports), regenerated by check:generated --fix.

维护者速读

  • 改了什么:FlowSchema 新增两类开始节点 config 拒绝(新文件 flow-start-config-refusals.ts):① config.timeRelative 为对象时,与之并列的 config.filter:没有任何读取方,时间相对扫描只读 timeRelative.filter(timeRelative: null 不判定);② config.schedule 读不出调度:带 type 的对象按已发布的 ScheduleSchema 判定(因此 { type: 'cron', cron }、{ type: 'interval', every } 两个带类型别名也被拒绝,提示改用 expression / intervalMs),其余值只在 normalizeSchedule 什么都读不出时拒绝。裸 cron 字符串和运行时接受的无类型别名({ cron } / { expression } / { every } / { intervalMs } / { at })照旧接受。新增三个导出、spec minor changeset。
  • 为什么改:Enforce-or-remove: four metadata shapes cloud's authoring rules warn about still parse clean (boolean required, open flow start config, orphan action nodes, roll-up filter keys) #31224 第 B 项,席位裁定 B1 选项 (a)。这两种写法解析通过却不生效:filter 放错位置时,日期窗口内每条记录都会触发流程;调度读不出时,定时流程不绑定(服务器日志里只有一条 warn),时间相对扫描退回每天 08:00 UTC。
  • 风险与回滚:收窄:所有解析流程的门(defineStack、os validate/build、保存门含草稿、发布、引擎注册)拒绝两种此前接受的写法。普查:filter 与 timeRelative 并列 0 处;对象形式的开始节点调度全部带 type 且合法(showcase、cli 夹具、dogfood 夹具);两个带类型别名 0 处;9 处裸字符串 cron(app-todo 2、hotcrm 7)照旧接受。回滚即 revert。
  • 先看:packages/spec/src/automation/flow-start-config-refusals.ts 的模块头和 flowStartScheduleReadsNothing;以及 packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts 的 34 条对齐钉。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/automation/flow-start-config-refusals.test.ts(12 条)与 pnpm --filter @objectstack/trigger-schedule exec vitest run src/schedule-reader-spec-parity.test.ts(34 条),预期全部通过。
  • 需要你做的:无,席位做契约复审后落地。

What changes

  1. packages/spec/src/automation/flow-start-config-refusals.ts (new) exports flowStartConfigRefusals(flow), flowStartScheduleReadsNothing(raw) and the FlowStartConfigRefusal type. The rules cover top-level start nodes only, at nodes[i].config.*:
    • config.filter beside config.timeRelative. Judged only when config.timeRelative is a non-null object, the test the trigger-kind resolver (flow-trigger-kind.ts) routes a sweep by, so timeRelative: null is not judged. Refused at nodes[i].config.filter: "nothing reads it … launches for every record in the date window. Move it into config.timeRelative.filter." The sweep reads timeRelative.filter (time-relative-trigger.ts).
    • config.schedule. Both readers (ScheduleTrigger, and TimeRelativeTrigger for a sweep's cadence) pass it through normalizeSchedule. When that yields null, a schedule flow is "not bound" (one warn), and a sweep falls back to TIME_RELATIVE_DEFAULT_CRON. The verdict, at nodes[i].config.schedule, by form:
      • bare string: not judged, unless empty or blank;
      • object with a type key: judged against the published ScheduleSchema, so the discriminant, an IANA timezone, a positive integer intervalMs and an ISO at must all hold; the first issue is quoted, with its sub-path. An object that passes is then also judged for readability, so a typed cron whose expression is a { dialect, source } envelope is refused, because the reader takes only a string. The schema does not declare cron or every on a typed object, so { type: 'cron', cron } and { type: 'interval', every } are refused although the reader binds them today (seat rulings 6714195854 (a), confirmed in 6717636410). When the refusal sees one of those keys it names the declared spelling (expression, intervalMs). Census for both: 0 in objectstack and hotcrm;
      • any other value: refused only when flowStartScheduleReadsNothing (an empty object, an unknown key, a number, an array, a non-positive interval). null or absent is not judged.
    • Each message names the shape, why it does nothing, and the spellings that work.
  2. packages/spec/src/automation/flow.zod.ts: FlowSchema.superRefine calls flowStartConfigRefusals beside the api-trigger refusals.
  3. packages/spec/src/automation/index.ts: re-exports the module.
  4. Generated by check:generated --fix:
    • api-surface/automation.json and export-origins/automation.json, each +3 names;
    • skills/objectstack-automation/references/_index.md, because job.zod.ts and its imports now reach the flow closure through the ScheduleSchema import.
  5. .changeset/31224-flow-start-config-refusals.md: '@objectstack/spec': minor, BREAKING banner, ADR-0087 not-required (no-migration-prescription), per seat ruling 6717636410 §1. The reason says what each arm refuses:

One reading, pinned. flowStartScheduleReadsNothing mirrors normalizeSchedule (@objectstack/trigger-schedule) branch for branch, including its truthiness and its "first candidate present decides" precedence. packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts pins flowStartScheduleReadsNothing(x) === (normalizeSchedule(x) === null) over 33 shapes, plus the absent-schedule case. If the two drift, the parse would either refuse a schedule that binds or accept one that never does. The pin is test-only, so @objectstack/trigger-schedule takes no changeset.

Tests

  • packages/spec/src/automation/flow-start-config-refusals.test.ts, 12 cases:
    • filter: refused at the parse (path and message pinned); accepted inside timeRelative, and on a start node without timeRelative; not judged beside timeRelative: null, both through the module and at the parse.
    • schedule accepted: the bare string, the three typed forms (cron with an IANA zone), and the five untyped aliases.
    • schedule refused at the parse: a typed cron with timezone: 'UTC+8' (path and sub-path pinned).
    • schedule refused for failing ScheduleSchema: an unknown type, intervalMs: 0, a cron with no expression, a non-ISO at.
    • typed aliases refused at the parse: { type: 'cron', cron } names expression and { type: 'interval', every } names intervalMs (path, sub-path and hint pinned); a typed cron with no alias key carries no alias hint.
    • schedule refused because the reader reads nothing: { cadence }, {}, [], 42, true, a blank string, { every: -5 }, and a typed cron with an envelope expression.
    • not judged: an absent or null schedule, and a non-start node.
    • precedence: the reader's precedence, pinned.
  • packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts: the 34-case parity pin above.
  • Census (text census of start-node config):
    • filter beside timeRelative: 0 in objectstack examples, fixtures and non-test packages, and 0 in hotcrm.
    • Object-form start schedules: examples/app-showcase ({ type: 'interval', intervalMs: 60000 }), the cli option-b-collection-zoo fixture and the dogfood schedule-organization-fixture (typed crons). All typed and all valid.
    • Typed aliases { type: 'cron', cron } and { type: 'interval', every }: 0 in objectstack and hotcrm.
    • Bare-string cadences: 2 in examples/app-todo and 7 in hotcrm, still accepted.

Acceptance notes

Verification

Head bdc95ace, two commits on the branch restarted from origin/main c2bc76e3, which includes #31337: 532b289f and the review-round fix. These are the fast checks the seat asked for, on bdc95ace:

  • pnpm --filter @objectstack/spec typecheck exits 0, including check:test-typecheck. That needs NODE_OPTIONS=--max-old-space-size=8192 locally; at the default heap it runs out of memory.
  • pnpm --filter @objectstack/spec check:generated exits 0 after a spec build, at the same heap. It covers check:api-surface, check:export-origins and the skills refs.
  • check:docs exits 0 (243 generated files in sync).
  • Touched tests: 181/181 across flow-start-config-refusals.test.ts (12), flow.test.ts and scripts/export-origins.test.ts. schedule-reader-spec-parity.test.ts 34/34.
  • check-adr-0087-registration exits 0, reading the new reason.
  • check-changeset-no-major with a pull_request event carrying this body exits 0.

Earlier, on the pre-restart local build of the same change, full suites passed:

  • spec: 913 files after the regen;
  • @objectstack/trigger-schedule: 12/12 files;
  • @objectstack/service-automation: 264/264 files.

CI runs the full set.


Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5oEQNYg7j3ueo5t6pV

…dule the readers cannot use

A start node's config is an open record, so the flow parse never judged two
shapes that then do nothing: a `filter` beside `timeRelative` (the sweep
reads `timeRelative.filter`, so the flow launches for every record in the
window), and a `schedule` normalizeSchedule reads nothing from (a schedule
flow stays unbound, a sweep falls back to daily 08:00 UTC).

FlowSchema now refuses both, through flowStartConfigRefusals: a typed
schedule object is judged against the published ScheduleSchema and then for
being readable; any other value only when the reader reads nothing, so the
bare cron string and the untyped aliases that bind today stay accepted.
flowStartScheduleReadsNothing mirrors normalizeSchedule branch for branch,
and trigger-schedule pins the two equal.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oEQNYg7j3ueo5t6pV
@github-actions github-actions Bot added the size/m label Nov 8, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 8, 2026
@github-actions

github-actions Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 8 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json, packages/spec/src/automation/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via FlowSchema (symbol, a top-level const))
  • content/docs/automation/jobs.mdx (via intervalMs (literal, a string literal in typedAliasHint))
  • content/docs/automation/webhooks.mdx (via intervalMs (literal, a string literal in typedAliasHint))
  • content/docs/protocol/kernel/lifecycle.mdx (via intervalMs (literal, a string literal in typedAliasHint))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via FlowSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-4.mdx (via FlowSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-5.mdx (via intervalMs (literal, a string literal in typedAliasHint))
  • content/docs/releases/v17/17-7.mdx (via FlowSchema (symbol, a top-level const))
  • content/docs/releases/v18/18-1.mdx (via FlowSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json, packages/spec/src/automation/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 64 of 227 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 171 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json eff90af6a2a68202ef167b8ed91aadecfaed90b7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5e54edfbd3f293be0157d78ff5df48634185032b — the merge of head bdc95ace88d92c914d25a04dba2cd2725ac1baab into base eff90af6a2a68202ef167b8ed91aadecfaed90b7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5e54edfbd3f293be0157d78ff5df48634185032b && git checkout 5e54edfbd3f293be0157d78ff5df48634185032b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eff90af6a2a68202ef167b8ed91aadecfaed90b7 bdc95ace88d92c914d25a04dba2cd2725ac1baab && git checkout -B drift-repro eff90af6a2a68202ef167b8ed91aadecfaed90b7 && git merge --no-ff bdc95ace88d92c914d25a04dba2cd2725ac1baab

node scripts/docs-audit/affected-docs.mjs --json eff90af6a2a68202ef167b8ed91aadecfaed90b7

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs eff90af6a2a68202ef167b8ed91aadecfaed90b7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK

Contract-review-claim: session_01FEkQ5mw7HFYn9o4LEGMQdK · 532b289 · 2026-11-08T04:57Z

Contract review of #31224 item B1 on the narrowing face, at CONTRACT_REVIEW_TIER.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 532b289f434b6d945b8c5c09cd59d6fdfc261eeb
Local-runs: none
Scope: .changeset/31224-flow-start-config-refusals.md, packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json, packages/spec/src/automation/flow-start-config-refusals.test.ts, packages/spec/src/automation/flow-start-config-refusals.ts, packages/spec/src/automation/flow.zod.ts, packages/spec/src/automation/index.ts, packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts, skills/objectstack-automation/references/_index.md + packages/triggers/trigger-schedule/src/schedule-trigger.ts, packages/triggers/trigger-schedule/src/time-relative-trigger.ts, packages/spec/src/system/job.zod.ts, packages/spec/src/shared/expression.zod.ts, packages/spec/src/automation/flow-trigger-kind.ts, packages/spec/src/automation/time-relative-trigger.zod.ts, packages/spec/src/kernel/metadata-read-decorations.ts, packages/services/service-automation/src/plugin-extension-api.ts, packages/services/service-automation/src/flow-definitions.ts, packages/metadata-protocol/src/protocol-save.ts, packages/metadata-protocol/src/metadata-diagnostics.ts, packages/lint/src/validate-flow-trigger-readiness.ts, ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts, examples/app-todo/src/flows/task.flow.ts, examples/app-showcase/src/automation/flows/index.ts, packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts, packages/cli/test/fixtures/option-b-collection-zoo.ts, skills/objectstack-automation/references/examples-flows.md, content/docs/automation/flows.mdx, content/docs/references/automation/time-relative-trigger.mdx, docs/adr/0087-metadata-protocol-upgrade-contract.md, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs, scripts/pm/record-recognisers.mjs, .claude/skills/pm-dispatch/SKILL.md, AGENTS.md, hotcrm 6961ab1 src/**/flows/*.flow.ts (31 files)

Inputs read: card #31224 body and every comment (triage 6711648543, claim 6712039823, plan 6712129038, seat rulings 6712144718 and 6714195854, dev reports 6713825906, 6716296533 and the B1 report 6717473839); PR #31376 body, file list (9 paths) and git diff origin/main...532b289f (merge-base c2bc76e3, +337/-0); the check-runs on 532b289f at three reads; for shape only, the merged siblings #31287 and #31337 with their records and changesets. Judged on the committed tree, not the PR body's account of it; nothing was built or run, and the corpus statements below are greps of objectstack at 532b289f and of hotcrm main at 6961ab1 (read through the GitHub API). Face and tier: reviewFace on the PR's 9 paths answers face schema (schema hits flow-start-config-refusals.ts, flow.zod.ts, automation/index.ts; rules hit skills/objectstack-automation/references/_index.md, a governed path), the paths deciding before the yes (narrowing) declaration would — the same CONTRACT_REVIEW_TIER, recordCarriesDesignJudgment true, and the Scope: line above holds every hit; this record is served at that tier on both faces' questions (the schema file against its ruling and first consumer, the narrowing against the published spec).

① Derived judgments

Parity with normalizeSchedule — exact, by reading. flowStartScheduleReadsNothing (flow-start-config-refusals.ts) is normalizeSchedule (schedule-trigger.ts:597) branch for branch: null/undefined (deliberately answered "not judged" rather than "reads nothing"), string trimmed and empty, non-object, type read only when a string, the cron arm's (expression && expression) || (cron && cron) truthiness, the interval arm's (intervalMs) || (every) with !intervalMs || intervalMs at most 0, the once arm's at string, and the fall-through. The "first candidate present decides" precedence ({ intervalMs: -1, every: 60000 } reads nothing; { intervalMs: 0, every: 60000 } reads every) is the same expression on both sides, and the 34-row pin in schedule-reader-spec-parity.test.ts holds the equivalence as a CI fact in both directions (including { every: NaN }, the { type: 7 } non-string type and the { dialect, source } envelope). Both runtime readers go through this one function: ScheduleTrigger.start (schedule-trigger.ts:756, binding.schedule ?? binding.config.schedule) and TimeRelativeTrigger.start (time-relative-trigger.ts:399, normalizeSchedule(binding.schedule) ?? daily 08:00 UTC), with the engine lifting config.schedule onto both bindings (plugin-extension-api.ts:282,304). So on the untyped arm there is no false refusal of a schedule the runtime binds and no false acceptance of one it drops.

The typed arm is NOT parity, and the ruling says so. A typed object is judged by the published ScheduleSchema (job.zod.ts: z.object per variant, not strict; expression: CronExpressionInputSchema, which accepts a non-blank string or a { dialect: 'cron', source } envelope; timezone an iana_time_zone member, default 'UTC'; intervalMs a positive int; at an ISO datetime). Two typed shapes the runtime binds today are therefore refused: { type: 'interval', every: 60000 } and { type: 'cron', cron: '0 8 * * *' } (the alias is read inside the typed branch of normalizeSchedule; the PR's own parity rows for these two shapes read false, so the diff documents that the runtime binds them). The seat ruled option (a) in 6714195854 — "an object schedule that carries type is judged against ScheduleSchema" — so the refusal is sanctioned; it is named here because the ruling's reason ("this card enforces what does not run, not what does") does not cover these two shapes, and because the changeset's ADR-0087 reason states the opposite (② below). Census: 0 instances of either shape in objectstack source, docs, skills or hotcrm (the one { type: 'interval', every: '5m' } is flow-trigger-kind.test.ts:51, which calls the resolver on a raw object and never parses FlowSchema). The other typed refusals are of shapes that never run: a non-IANA timezone is copied through by normalizeSchedule, then computeTickWindow (schedule-trigger.ts:230-236) catches croner's throw and answers no window, and job.zod.ts's own header records the cron adapter's FAILED TO SCHEDULE for the same value; intervalMs: 0, a missing expression, a non-ISO at and an unknown type all read nothing. The envelope case (passes the schema, reader reads nothing) is correctly caught by the second judgment.

config.filter beside config.timeRelative — no reader exists. The sweep reads its row filter from the descriptor only (time-relative-trigger.ts:212, ...(desc.filter ?? {}) ANDed with the date range); deriveTriggerBinding lifts condition, schedule, organization and timeRelative.object, never config.filter; the published descriptor schema (time-relative-trigger.zod.ts) teaches filter inside timeRelative. Every other config.filter reader in the repo is a CRUD node's (validate-flow-trigger-readiness.ts:1188 reads filter.id.$in on ROW_ADDRESSING_CRUD_NODE_TYPES; validate-empty-combinators.ts, authoring-rules.ts:1102), and the record-change and api triggers read condition, not filter. The refusal fires only when timeRelative is present, so a record-change flow's start node keeps any stray filter it carries, and a sweep whose filter sits inside the descriptor (the showcase showcase_task_due_reminder, the docs, the skill) is untouched. Nit (should-fix, wording): the guard is config.timeRelative !== undefined, while the resolver's is != null && typeof === 'object' (flow-trigger-kind.ts), so timeRelative: null beside a filter is refused with a sweep message for a flow the engine does not treat as a sweep; isRecord(config.timeRelative) would align the two.

Top-level start nodes only — right. Every reader picks flow.nodes.find(type === 'start') on the top-level list (plugin-extension-api.ts:201, flow-trigger-kind.ts), region bodies live inside container config and never hold a start node, and the test pins that a non-start node's config.schedule is not judged. A second top-level start node is judged too (its config is never read, so this over-refuses harmlessly, and item C already refuses a second start at lint).

Doors, and which keep. The rule is in FlowSchema.superRefine, so it reaches every door that parses a flow: defineFlow/defineStack (flow.zod.ts:1986), os lint/validate/build (validate-flow-region-shape.ts:121, flow-undeclared-cycle.ts:61), the metadata save door for EVERY state including drafts (protocol-save.ts:1424-1428, resolveOverlaySchema(...).safeParse, refused 422 with the issue path nodes.0.config.schedule), the publish and restore doors that go through the save door's judge, and engine registration (flow-definitions.ts:212, FlowSchema.parse(converted) AFTER the ADR-0087 conversion replay). A stored flow carrying a refused shape: at cold boot registerFlow throws and the engine records the refusal per flow (recordFlowRefusal, flow-definitions.ts:241; flowRefusals map, not a boot abort), the read path serves it with _diagnostics.valid: false (metadata-diagnostics.ts:312-337, a safeParse, never a refusal on read), and the next active save is refused until the author applies the cure. That is the same migration profile as every earlier parse narrowing (#4001 closed shapes, #4277), and the refused flow was unbound or on the silent default before, so no running automation stops; the PR body names the door set correctly. The draft door is where the parse altitude bites (④ and the designer finding in ③).

Message, location and cure — right, two nits. Path ['nodes', i, 'config', 'filter' | 'schedule'] is the authored location (pinned). The filter message names the reader, the consequence (every record in the window) and the move. The typed message quotes the first ScheduleSchema issue with its sub-path (schedule.timezone, pinned) and lists the three typed spellings and the bare string; the untyped message names the three readable keys and the two consequences (unbound schedule flow, daily 08:00 UTC sweep). Nits: the schedule messages speak of "a schedule flow" or "a sweep" even on a record-change or api start node that carries a stray schedule key (nothing reads it there; the refusal is still of a dead key, the sentence is just wider than the case); and for a sweep the refused cadence is one the runtime silently replaces, not one that stops the flow — the message says so, correctly.

Census — nothing shipped, example, dogfood or hotcrm is refused. filter beside timeRelative: 0 in examples/**, packages/**, skills/**, content/docs/** and hotcrm (hotcrm has no timeRelative flow at all). Start-node schedule values: bare strings in examples/app-todo/src/flows/task.flow.ts:57,167 and seven hotcrm flows (campaign-completion, contract-expiration, contract-renewal, forecast-snapshot, opportunity-stagnation, quote-expiration, task-due-reminder), all accepted; typed { type: 'interval', intervalMs: 60000 } (app-showcase/.../flows/index.ts:445), typed crons in schedule-organization-fixture.ts:74 and option-b-collection-zoo.ts:324 (with timezone: 'UTC'), all valid under ScheduleSchema and readable. The skill and docs teach { type: 'cron', expression } and timeRelative.filter only (examples-flows.md:31,98, flows.mdx:2802,2845,2918, time-relative-trigger.mdx:42-59). Dogfood Regression Gate concluded success on this head.

Tests. The 10 spec cases pin the filter refusal and both acceptances, each accepted schedule form (bare string, three typed, five untyped aliases), the IANA refusal at the parse with its sub-path, four ScheduleSchema refusals, seven reads-nothing refusals, the envelope, null/absent/non-start not judged, and the precedence. The parity file pins 33 shapes plus the absent case. The ledgers (api-surface, export-origins) carry exactly the three new names at their sorted positions, FlowStartConfigRefusal as (interface) matching its declaration; check:generated runs in Lint & Repo Gates, success on this head.

② Semver level

  • Changeset @objectstack/spec: minor — right. AGENTS.md Post-Task Checklist step 3: a kind other than none takes at least minor on a moved package; check-changeset-no-major.mjs (level axis): narrowed must grade a moved package minor and sit beside a (narrowing) arm — both hold; Check Changeset concluded success on this head.
  • Clause-②: yes (narrowing) — right. The value answers whether the diff widens a public surface (clause2-line.mjs); @objectstack/spec/automation gains three exports, so yes; the parse refuses two shapes it accepted, so (narrowing). Same reading the item-C record adopted for FLOW_NODE_UNREACHABLE (and the precedent 46d2b6503). Body and changeset agree; reviewFace maps the arm to this face.
  • Metadata-protocol: narrowed — right. "A value refused or a requirement tightened is narrowed" (step 3).
  • Skills-docs: none — … — present in body and changeset, and the reason checks out (census above: skills and docs teach only the accepted forms). The _index.md regeneration follows mechanically from importing job.zod.ts into the flow closure (its imports are value-domain.zod.ts, hook-body.zod.ts, schedule-organization.zod.ts, which are exactly the four added rows), and check:skill-refs is inside the green check:generated.
  • BREAKING banner and the ! in fix(spec)!: — consistent, no script reads the title ! (item-C record, unchanged tree).
  • MUST-FIX — the ADR-0087 marker not-required (no-migration-prescription) is contradicted by the changeset's own body, and by ADR-0087 D2, for the filter arm. The gate defines the category as "REFUSED when the changeset's own body carries a migration prescription … instructions for rewriting a consumer's code" (check-adr-0087-registration.mjs, the honesty table). This changeset carries one: "FROM config: { timeRelative: { … }, filter: { status: 'active' } } TO config: { timeRelative: { …, filter: { status: 'active' } } }", plus "move the filter into timeRelative.filter" under "What an author does". The gate did not see it only because its detector reads a FROM/TO pair with an arrow on one line, a line-initial vertical pair, or a rewrite table (FROM_TO_LABEL_RE, VERTICAL_FROM_RE), and this spelling is mid-line bold with no arrow — the detector-miss class the gate's own docblock records for fix(service-package): classify a publish driver fault as 5xx and stop returning driver text as caller data (#8131) #8277 and [finding] ADR-0087's no-migration-prescription detector missed a FROM -> TO line again — an exemption the categories inherit can be held by silence, and #8299 closed the category gap, not this one #18745, not an acceptance. The contradiction is also substantive: the filter arm is a lossless field move of an authored key to its declared home, which is the D2 scope by name ("renames, alias removals, field moves"), and ADR-0087's policy paragraph says a lossless break lands as a chain step (a retiredFromLoadPath conversion plus the D3 semantic entry) in the same release — and registerFlow replays conversions BEFORE the parse (flow-definitions.ts:191-212), so a conversion is exactly what would keep an already-stored flow of that shape loading instead of landing in flowRefusals. The marker's own prose ("there is no rewrite that keeps what the author meant in every case") is true of the schedule arm and false of the filter arm. The siblings differ materially: C's and D's changesets carry no rewrite because none preserves intent, which is why their no-migration-prescription stood. Two cures, the seat's choice (③): register the filter move as a D2 conversion with its fixture and semantic entry and write registered (ids); or, if the seat rules that an undeclared key on an open-record config is no D2 surface (the 3b — wire the flow executors to parse() their config, and tighten the undeclared-key warning into an error #4277 class), write that ruling into the marker's reason and remove the FROM/TO rewrite spelling the category forbids while keeping the one-line cure — a seat ruling, because AGENTS.md step 3 ("breaking changesets must carry their migration … FROM → TO") and the category's definition pull in opposite directions for this class and neither sibling faced it. Either way the shipped text changes, and a factual error in a released changeset is amendable only by a dedicated docs PR (AGENTS.md, CHANGELOG.md row).
  • Should-fix (text): the same marker says a typed schedule "the published ScheduleSchema refuses … never bound as written". { type: 'interval', every: 60000 } and { type: 'cron', cron: '…' } bind today (① above, and the PR's own parity rows). The sentence should say what is true: typed objects are judged against the published schema by ruling, and the two typed aliases the reader happens to bind are refused with their cure named.

③ Boundary flags

  • MUST-FIX — the ADR-0087 disposition (② above). Escalated to the seat for which cure: a registered D2 conversion for the filter move (plus the D3 entry), or a ruled exemption written into the marker with the rewrite spelling removed. Both are edits to the changeset at least; the first is code.
  • Escalated — typed aliases the runtime binds are refused (ruling (a) mechanism vs its reason). { type: 'interval', every } and { type: 'cron', cron } run today and are refused by the typed arm. The ruling's text sanctions the refusal; its stated reason does not. Corpus zero in both repos. The seat confirms the refusal stands (and the changeset sentence above is corrected), or carves the two typed aliases out. Not a must-fix: the behaviour follows the ruling as written.
  • Should-fix (follow-up card, ui/**, outside this PR's surface) — the Studio designer produces a refused draft. scheduleWithExpression (flow-node-config.ts:1710) rewrites the whole config.schedule block on each cron edit; clearing the cron on a typed block leaves { type: 'cron' } or { type: 'cron', timezone: 'UTC' } (only an untyped { expression } collapses to undefined), and the field's own help text says "A time-relative sweep defaults to daily if left empty". On main that draft stored and the schedule flow silently never bound (the sweep defaulted); on this head the draft save is refused 422 at nodes.i.config.schedule with the cure. The refusal is correct under ruling (a) and names the fix, so it is not blocking — but it is a draft-door refusal of a designer-authored intermediate shape, the exact reason item C moved to lint (the C changeset says so in its own words), and the designer should drop a typed block that no longer carries a cadence. The seat decides whether this is a follow-up card or an accepted behaviour; the PR body already declares drafts among the refusing doors.
  • Dev open_questions: none declared. Flags carried from 6717473839: the full dispatch-gates set and the dogfood battery were not run on this head (seat asked for the fast set) — covered by CI below, Dogfood Regression Gate success; the full suites ran on a pre-restart tree — Test Core shards cover the head. Not named by the dev: the PR adds a test file under packages/triggers/trigger-schedule/src/, which the claim's file surface (6712039823) does not list ("⛔ runtime engines" is the nearest row). It is test-only, moves no runtime source and takes no changeset, so it is an undeclared but harmless deviation; the seat notes it.
  • Should-fix (wording): the timeRelative: null guard mismatch and the record-change/api schedule wording (① above).
  • Check-runs on 532b289f at the fourth read: success — Check Changeset, Lint & Repo Gates, every Lint · lane (ESLint; repo & tooling; docs & code guards; CI, release & engine; PM dispatch-gates; the three scoped lanes), Governed Surface Queue Guard, Spec property liveness, Dogfood Regression Gate, Build Core, Build & E2E UI, Live E2E UI, Build UI Deps, Type Check · source gates / consumer gates / debt ledger, Test Core (2, 6, 7, 8 of 8), Test UI (1, 4, 5, 7, 8, 10, 11, 12, 15 of 16), Check PR Size, Plan Test Core shards, PR-body guards, Flag docs affected, Auto Label, filter; no failure; still in progress — Type Check · workspace (the lane that runs spec's check:test-typecheck), Type Check UI, Test Core (1, 3, 4, 5 of 8), Test UI (2, 3, 6, 9, 13, 14, 16 of 16); skipped (opt-in or path-filtered) — the rest. The verdict does not wait on the in-progress ones: the must-fix above decides it alone, and none of the completed gates reads the ADR-0087 spelling at issue.
  • Self-check of this record through the recogniser's readers, as data: readServedTier reads CONTRACT_REVIEW_TIER and servedTierStands answers true; readLocalRuns reads none and localRunsStand true; locateReviewOfRecord on a pair holding this text as a PR comment answers found on 532b289f434b6d945b8c5c09cd59d6fdfc261eeb; the Reviewed-by key line matches REVIEWED_BY_LINE when read per line, as the readers read it.

④ Design judgment

Against the published spec on main: FlowNodeSchema.config is an open record (flow.zod.ts:694), so a start node's filter and schedule were accepted unjudged; TimeRelativeTriggerSchema declares filter inside the descriptor and its guidance table says schedule is a sibling cadence "daily at 08:00 UTC" when omitted; ScheduleSchema is published for jobs and, through this diff, now reaches flow authors — the thing the card's B(iii) asked for. The narrowing takes no declared capability away at any consumer: every schedule the two readers bind in the untyped form still parses (pinned), the bare cron string the nine authored flows use still parses, the typed forms the docs and skill teach still parse, and timeRelative.filter is untouched. What it takes away is the typed aliases (ruling-sanctioned, census zero) and shapes that never bound or silently defaulted — it makes the spec's own description of config.schedule real (ADR-0078, Prime Directives #10 and #12), and the filter refusal makes a sweep do what its author wrote instead of launching for every record in the window.

Altitude: parse, by ruling. The card's Expected names "the checks in FlowSchema's superRefine"; 6712144718 picks B1 as exactly that; 6714195854 refines the schedule reading without moving the altitude. The spec already refuses misplaced start-node keys at parse (the top-level schedule/objectName guidance at flow.zod.ts:1112, apiTriggerSecretRefRefusals and apiTriggerSignatureRefusals on the api start node), so B1 sits beside live precedent, and unlike item C the verdict needs nothing a single flow's parse cannot see. The cost of parse is the draft door (③): item C's designer shape was a graph the author could not help producing mid-edit; B1's is a cadence block the designer leaves half-written on clear, which the designer can fix on its own side. The ruling allows the altitude; the draft consequence is named for the seat rather than ruled on here.

Duplication: the spec now carries a copy of the runtime's normalizeSchedule decision, inverted. The parity pin in trigger-schedule holds it honest in both directions over every form either side distinguishes (a drift in either file reds the trigger package's suite), and the spec cannot import the runtime, so a pinned mirror is the available shape. The pin covers only the reads-nothing half; the typed half is the published ScheduleSchema itself, so there is no second copy to drift there — the gap between the two halves is the typed-alias set named in ① and escalated in ③. The ADR-0087 disposition is the design defect of this head: a lossless field move declared "no migration prescription" while prescribing one — the FAIL below is that, not the refusals, which hold against the readers they cite.

Implemented-by: claude/project-thread-52y8an
Reviewed-by: session_01FEkQ5mw7HFYn9o4LEGMQdK

VERDICT: FAIL

…ases, restate the ADR-0087 reason

The filter refusal now fires only when `config.timeRelative` is a non-null
object, the test the trigger-kind resolver routes a sweep by. A typed
`{ type: 'cron', cron }` or `{ type: 'interval', every }` schedule is refused
with the declared spelling named (`expression`, `intervalMs`). The changeset's
ADR-0087 reason says what is true of each arm, and the release note no longer
carries a FROM/TO rewrite: the parse refusal names where the key belongs.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oEQNYg7j3ueo5t6pV
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK

Contract-review-claim: session_01FEkQ5mw7HFYn9o4LEGMQdK · bdc95ac · 2026-11-08T05:26Z

Delta review of the B1 fix round, answering FAIL 6717630512 under seat ruling 6717636410.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bdc95ace88d92c914d25a04dba2cd2725ac1baab
Local-runs: none
Scope: .changeset/31224-flow-start-config-refusals.md, packages/spec/src/automation/flow-start-config-refusals.test.ts, packages/spec/src/automation/flow-start-config-refusals.ts, packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json, packages/spec/src/automation/flow.zod.ts, packages/spec/src/automation/index.ts, packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts, skills/objectstack-automation/references/_index.md + packages/spec/src/automation/flow-trigger-kind.ts, packages/spec/src/system/job.zod.ts, packages/triggers/trigger-schedule/src/schedule-trigger.ts, scripts/check-adr-0087-registration.mjs, docs/adr/0087-metadata-protocol-upgrade-contract.md, AGENTS.md

Delta record on the FAIL of record 6717630512 (head 532b289f). Inputs new since that record: seat ruling 6717636410 on #31224, read in full; the patched PR body; the one commit bdc95ace on 532b289f (git diff 532b289f bdc95ace: 3 files, +58/-7) and the net diff git diff origin/main...bdc95ace (merge-base unchanged at c2bc76e3; 9 files, +388, the previous +337 plus this commit, so the interdiff IS the commit); the check-runs on bdc95ace at two reads. Everything the previous record found right is carried unchanged: the six paths this commit does not touch are byte-identical to the reviewed head, and the three it touches are judged below hunk by hunk. Same tier and face as before (reviewFace on the 9 paths: face schema, the same CONTRACT_REVIEW_TIER; the Scope: line holds every hit). Nothing built, run or re-run; the gate conclusions below are the check-runs', and the two pure readers named in ② were run on the changeset text as data.

① Derived judgments

(a) The commit, hunk by hunk. Three hunks in flow-start-config-refusals.ts: the module header gains the ruling's sentence on typed aliases; a non-exported typedAliasHint(raw) returns one sentence when raw.type === 'cron' carries cron and no expression, or raw.type === 'interval' carries every and no intervalMs, else the empty string; the typed-arm refusal splices that sentence after the quoted ScheduleSchema issue and before the unchanged consequence and spellings; and the filter guard becomes const isSweep = config.timeRelative != null && typeof config.timeRelative === 'object'. Two new tests: timeRelative: null beside a filter is not judged, through the module AND at the parse (startIssues), as the ruling's §3 asked ("Pin it"); and the two typed aliases are refused at the parse with path nodes.0.config.schedule, the sub-path (schedule.expression / schedule.intervalMs) and the hint sentence pinned, with a control that a typed cron carrying no alias key gets no alias hint. The changeset edits are judged in ②. No export is added or changed (typedAliasHint is module-private), so the untouched ledgers stay right; flow.zod.ts, index.ts, the parity file and _index.md are byte-identical to 532b289f.

The guard is exactly the resolver's test. flow-trigger-kind.ts:81 reads if (config.timeRelative != null && typeof config.timeRelative === 'object') return 'time_relative'; and the new guard is that expression token for token (!= null, then typeof === 'object'). The seat's reading holds: an array timeRelative is typeof 'object' and routes as a sweep in the resolver, so the dev was right to copy the resolver rather than use isRecord (which excludes arrays) — with isRecord a timeRelative: [] start node would be routed as a sweep by the engine yet escape the filter judgment. null and undefined both fall out of != null, so the previous record's nit (timeRelative: null refused with a sweep message) is closed, and the engine's deriveTriggerBinding agrees because it switches on the same resolver's answer. The refusal fires for exactly the start nodes the engine would bind to the time-relative trigger, no more.

The typed-alias hint — right, and only where it applies. For { type: 'cron', cron: '0 8 * * *' } the discriminated union selects the cron variant, the first issue sits at expression (required, missing), so the quoted sub-path is schedule.expression and the hint names expression; for { type: 'interval', every: 60000 } the first issue is at intervalMs and the hint names intervalMs. The hint is suppressed when the declared key is present too (then the schema judges that key on its own merits, and the alias is an ignored extra on a non-strict z.object) and when no alias key is present (control pinned). The hint names the two spellings the ruling's §2 required. Nothing changes on the untyped arm, so the parity pin (33 shapes plus absent) still describes exactly what flowStartScheduleReadsNothing does; normalizeSchedule (schedule-trigger.ts:597) is untouched on origin/main at this read.

(d) Nothing new turned false. Messages: the filter message is unchanged and now reaches only start nodes the engine routes as sweeps, so its "a time-relative sweep takes its row filter from timeRelative.filter" sentence is true of every node it reaches. The typed message's new sentence is true (the published schema declares expression and intervalMs, job.zod.ts). Pins: the 12 spec cases and the 34 parity rows describe the tree; the startIssues helper still filters on nodes.0.config, so the parse-level pins are real parse pins. Ledgers: unchanged, still the three names. Parity file: unchanged, and no change to the function it pins. Module header: the ruling's sentence is attributed ("By ruling") and names the ruling id in the helper's docblock. PR body: the door list, the census (now including the two typed aliases at 0 — verified in the previous record's greps of both repos), the 12-case count (counted: 12 it( calls) and the "second round" acceptance note are accurate; the body still quotes the filter message's "Move it into config.timeRelative.filter" — that is the refusal message's text quoted in a PR body, not the release note, and the category reads the changeset.

② Semver level

  • Changeset @objectstack/spec: minor, Clause-②: yes (narrowing), Metadata-protocol: narrowed, Skills-docs: none — …, BREAKING banner — unchanged from the reviewed head and still right (previous record ②). Check Changeset concluded success on bdc95ace twice (the push run and the body-edit run), and that job runs both check-changeset-no-major.mjs and check-adr-0087-registration.mjs --base.
  • (b) The must-fix is closed by the ruling's cure. Ruling 6717636410 §1 rules the filter arm the 3b — wire the flow executors to parse() their config, and tighten the undeclared-key warning into an error #4277 class — an undeclared key on the open-record config, no declared N−1 shape, so no D2 conversion — and keeps not-required (no-migration-prescription) with a reason that must be true of both arms. The new reason is: FlowNodeSchema.config stays an open record; the filter arm refuses an undeclared key that the published descriptor declares only inside timeRelative, so no protocol step migrates from it; the schedule arm refuses values ScheduleSchema refuses or the reader reads nothing from; each message names the cure. Read against the tree, every clause is true: flow.zod.ts:694 is the open record, time-relative-trigger.zod.ts declares filter inside the descriptor, the typed arm is ScheduleSchema.safeParse, the untyped arm is the pinned mirror, and the two messages carry their cures. The false clause of the old reason ("never bound as written") is gone, and nothing replaces it with a claim about the typed aliases other than the true one (the schema does not declare them). The whole changeset was read for any prescription spelling the category forbids — not only the detector's patterns: the bold FROM/TO pair is gone; the "move the filter into timeRelative.filter" instruction is gone; "What an author does" now says the parse refusal names where the key belongs or the spellings the readers take, which describes the refusal rather than instructing a rewrite; the bullets describe what is refused and what stays accepted, and the one sentence that names expression and intervalMs says the refusal names them. No arrow, no vertical FROM/TO, no table, no "migration" framing, no old-to-new mapping in prose. As data, findMigrationPrescription(body) returns null and readDisposition(body) reads verdict: not-required, category: no-migration-prescription, ids: [] with the new reason — the detector had also returned null on the old body, which is why the previous FAIL rested on reading, not on the gate, and on this head the reading and the gate agree. The AGENTS.md step-3 sentence that pulled the other way ("breaking changesets must carry their migration … FROM → TO" for a removed authorable key) is answered by the ruling: the key was never declared, so nothing authorable under the published spec is removed, and the rewrite lives in the refusal message. The maintainer may overrule (6712144718 reserved that), and the changeset would then need the conversion; as ruled, the disposition is truthful and consistent.
  • The should-fix on the "never bound as written" sentence — closed. The bullet now says typed objects are judged against the published schema, names the two typed aliases the reader binds today as refused with the declared spellings, and states the census (0 in both repos, verified in the previous record). Ruling §2 confirms the refusal; the parity file still documents, by its two false rows, that the runtime binds them — and the changeset now says the same, so the two texts agree.

③ Boundary flags

  • Must-fix of 6717630512 — closed (② above).
  • Typed aliases — ruled, §2, and the hint the ruling asked for is present and pinned (① above). Nothing to escalate.
  • Guard alignment — ruled §3, taken with the resolver's exact test rather than isRecord; judged exact, including the array case (① above). Pinned at the parse and through the module.
  • Studio designer's typed block with no cadence — filed as ui: flow designer leaves a typed schedule block with no cadence when the cron is cleared, which the flow parse now refuses #31383 by the seat (ruling §4), outside this PR's surface. Not re-judged here; the draft-door behaviour on this head is as the previous record described and as the PR body declares.
  • Test file under packages/triggers/trigger-schedule/src/ — accepted deviation, ruling §5. Closed.
  • Wording nit carried, not blocking: the typed and untyped schedule messages still say "a schedule flow stays unbound and a time-relative sweep falls back to daily 08:00 UTC" on a record-change or api start node carrying a stray schedule key (nothing reads it there). Should-fix at most; the refusal is still of a dead key. Also "What an author does" says "a schedule that binds today in a form the published schema declares" — the untyped aliases bind today and are not declared by the schema, yet stay accepted (the bullet above it says so); the sentence is under-inclusive, not false.
  • Dev flags in the PR body: fast checks only on bdc95ace (spec typecheck incl. check:test-typecheck, check:generated, check:docs, 181 touched tests, the two changeset gates); the full set is CI's, below. No open_questions posted for this head at the time of this read (no new os-dev-report on the card since 6717473839 was read; the PR body's "Second round" note is the dev's account).
  • (e) Check-runs on bdc95ace at the third read: success — Check Changeset (two runs: the push and the body edit), Governed Surface Queue Guard, Dogfood Regression Gate, Spec property liveness, Build UI Deps, Type Check · source gates, Lint · docs & code guards, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, PR-body guards, Plan Test Core shards, Flag docs affected, Check PR Size, Auto Label, filter; no failure; still in progress — Build Core, Build & E2E UI, Live E2E UI, Lint · ESLint, Lint · repo & tooling gates, Lint · CI, release & engine gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Type Check · workspace / consumer gates / debt ledger, Type Check UI, Test Core (1–8 of 8), Test UI (1–16 of 16); not yet reported — Lint & Repo Gates; skipped (opt-in or path-filtered) — the rest. The delta touches one spec module, its test and a changeset, so the lanes that can still change are the spec typecheck (Type Check · workspace, which the dev reports exit 0 locally at the 8 GB heap) and Test Core; this record does not wait on them. If a required lane reds on this head, the landing waits on that red by Multi-agent discipline §7 regardless of this PASS.

④ Design judgment

The design judged on 532b289f stands: the narrowing takes no declared capability away at any consumer, parse is the ruled altitude beside live precedent, and the spec's inverted copy of normalizeSchedule is pinned in both directions. This head removes the one design defect that record named. The ADR-0087 answer is now the one the tree supports: a key the published spec never declared on an open record has no N−1 shape for a D2 conversion to migrate, which is the same reasoning under which #4277's getAll was refused without a ledger entry, and the ruling writes it into the marker so the question is answered in writing (the gate's purpose) rather than by a detector finding nothing. The typed-alias refusal is now stated for what it is — the published schema's accept set applied to anything that claims a type — with the ruling's hint turning the one case where the runtime's leniency and the spec disagree into a one-line cure, so the parity gap between the two halves is named in the changeset, in the module header and in the message instead of being left for a reader to infer from two false rows. The guard change is a strict narrowing of the filter refusal to the engine's own sweep set, so the rule now refuses a dead filter exactly where the engine would have ignored it. No must-fix open on this head.

Implemented-by: claude/project-thread-52y8an
Reviewed-by: session_01FEkQ5mw7HFYn9o4LEGMQdK

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK

维护者速读

  • 改了什么:FlowSchema 在解析时新增两类开始节点 config 拒绝(新文件 flow-start-config-refusals.ts,3 个新导出)。
    • ① config.filter 与 config.timeRelative 并列:没有任何读取方,时间相对扫描只读 timeRelative.filter。
    • ② config.schedule 读不出调度:带 type 的对象按已发布的 ScheduleSchema 判定,其余值只在运行时 normalizeSchedule 什么都读不出时拒绝。
    • 裸 cron 字符串和运行时接受的无类型别名照旧接受。
  • 为什么改:这是 Enforce-or-remove: four metadata shapes cloud's authoring rules warn about still parse clean (boolean required, open flow start config, orphan action nodes, roll-up filter keys) #31224 的 B1 项,也是最后一项。这两种写法解析通过却不生效:
    • filter 放错位置时,日期窗口内的每条记录都会触发流程;
    • 调度读不出时,定时流程不绑定,扫描退回每天 08:00 UTC。
  • 影响面:spec minor,收紧了元数据协议(narrowed)。普查结果:objectstack 和 hotcrm 中被拒的写法都为 0;9 处裸 cron 和全部带类型的调度照旧通过。已存的此类流程会在加载时按单个流程记为拒绝,不会让启动中止。
  • 风险与回滚:拒绝逻辑与运行时 normalizeSchedule 逐分支对齐,由 34 条对齐钉双向锁定。回滚即 revert。
  • 先看:packages/spec/src/automation/flow-start-config-refusals.ts 的模块头。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/automation/flow-start-config-refusals.test.ts
  • 需要你做的:无,由席位落地。合并前你仍可否决。有一条席位裁定你可以推翻:filter 写错位置按「开放记录上的未声明键」处理,不做 ADR-0087 D2 转换(6717636410 §1)。
  • 席位意见:ACCEPT。Fable 契约复审首轮 FAIL(6717630512):changeset 一边声明「无迁移处方」,一边又写了 FROM/TO 改写,自相矛盾。席位裁定后修复,delta PASS(6717765448)。设计器会产出无节奏的带类型调度块,此问题另开 ui 卡 ui: flow designer leaves a typed schedule block with no cadence when the cron is cleared, which the flow parse now refuses #31383。

席位核对

Verdict: ACCEPT · head bdc95ace88d92c914d25a04dba2cd2725ac1baab · card #31224 (item B1, last item) · claim 6712039823 (amended 6712144718, 6714195854, 6717636410) · report 6717473839

  1. Shape: draft, base main, branch restarted from main after fix(lint)!: judge a roll-up's summaryOperations.filter keys against its child object #31337 merged. Refs #31224 with no closing keyword; the seat closes the card on merge. 9 files, +388/-0.
  2. Premise measured: flowStartScheduleReadsNothing matches normalizeSchedule (schedule-trigger.ts:597) branch for branch, and the 34-row parity pin in trigger-schedule holds it in both directions. No reader of config.filter beside timeRelative exists (the sweep reads desc.filter, time-relative-trigger.ts:212). The sweep guard is the resolver's own test (flow-trigger-kind.ts:81).
  3. Gates: every check-run on this head is success (57) or skipped by its own path filter (11); mergeable_state: clean. check-governed-merges --pr 31376, run in the card thread's worktree at this head with the toolchain installed: NOT governed. The generated-surface exception ([finding] skills/*/references/_index.md is generator-owned (gen:skill-refs) yet sits inside the skills/** governed-merge fence — every spec PR touching an indexed headline is forced to carry a governed-surface file #11705) lifted skills/objectstack-automation/references/_index.md as a pure regeneration. My own checkout has no toolchain, so there it failed closed.
  4. Accepted deviations:
    • ADR-0087: not-required (no-migration-prescription) by seat ruling 6717636410 §1. The filter arm is an undeclared key on the open-record config (the 3b — wire the flow executors to parse() their config, and tighten the undeclared-key warning into an error #4277 class), so a D2 conversion has no N−1 shape to migrate. The changeset now carries no rewrite prescription; the refusal message names the cure.
    • The typed aliases {type:'interval', every} and {type:'cron', cron} are refused under ruling 6714195854 (a), confirmed in §2. The census is 0 and the message names intervalMs / expression.
    • A test-only file under packages/triggers/trigger-schedule/src/ sits outside the claim surface (§5).
  5. Out of scope and where it went:
  6. Landing: Clause-②: yes (narrowing) / Metadata-protocol: narrowed, minor on @objectstack/spec. CONTRACT_REVIEW_TIER: FAIL 6717630512, then delta PASS 6717765448 on this head.

Ruling-ref: the maintainer, 2026-11-01T12:11Z: 「定级你可以自己决定」; 2026-11-07T16:00Z: 「你有 fable ,车道契约复审可以自己做」; 2026-11-07T16:59Z: 「你应该使用 pm-dispatch 技能审核和落地pr」; standing landing authorisation 2026-10-25T11:35Z: 「按照 pm-dispatch 规则复核过的都授权」.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 8, 2026 05:58
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 8, 2026 05:58
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit 71d094c Nov 8, 2026
69 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-52y8an branch November 8, 2026 06:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants