Repository navigation
feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision, off by default - #18198
Conversation
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
📓 Docs Drift CheckThis PR changes 7 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 146 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 219922a7e9fbd939286d29b7cd267f885b91b7f7 && git checkout 219922a7e9fbd939286d29b7cd267f885b91b7f7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8b81ab60d7b02e5540e8a36917cc86478bfbf899 6b797b6dc8cf9abe2beef72d72b7fc3666ac6bdc && git checkout -B drift-repro 8b81ab60d7b02e5540e8a36917cc86478bfbf899 && git merge --no-ff 6b797b6dc8cf9abe2beef72d72b7fc3666ac6bdc
node scripts/docs-audit/affected-docs.mjs --json 8b81ab60d7b02e5540e8a36917cc86478bfbf899
|
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
⛔
|
| the pending note says | after ruling G |
|---|---|
a time-triggered flow MUST declare config.organization, full stop |
true only under a walled posture (group / isolated) with scheduled work switched on |
| (no deployment gate exists) | package-authored scheduled work is gated by a deployment variable, OFF by default in every posture and every kernel — while off, nothing below the banner happens, because nothing arms |
| 「Nothing that was refused becomes admitted」 | single + on: a flow that note would have left unarmed now binds and runs |
announces the lint finding flow-schedule-organization-missing |
that finding is deleted (Q4, addendum 5642795312) — lint can see neither the switch nor the posture |
Why it lands in the same window rather than as a follow-up
The ruling's own timing clause: all of it 「must land in the same launch window as PR #17334, before the next release cut — otherwise 17.x ships a bind-time contract this ruling immediately narrows」.
What is being asked of the maintainer — one confirmation
Confirm that the pending release note for #16659 may be corrected in place by this PR, on the grounds that ruling G — which you approved on 2026-09-12 — narrows the behaviour that note describes, and that the narrowing ships in the same window so no released version ever saw the wider rule.
⛔ This seat does not clear it itself. A red-by-design carve-out exists in the protocol, but using it here would be precisely the 「routing around it」 the gate's own text names — and a release decision is on the manual floor regardless.
Status of the rest of this PR
⛔ Not a claim that everything else is green. The other jobs were still converging when this was written; any other red is a separate matter and will be diagnosed on its own, ⛔ not folded into this expected one.
Generated by Claude Code
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
… re-read The reason was re-derived inside getTriggerBindingAudit() from a live resolveScheduledWorkPolicy() read. The audit is consumed long after the bind, so an environment that moved in between made it report 'binding failed' for a flow whose trigger was never called — the exact reading ruled item 6 forbids. Record the refusal at the gate; clear it the moment a flow gets past. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
The four declarative-job suites measure the READER, not the deployment, so they arm the switch. Adds the OFF-state pins ruling G Q3 implies: nothing schedules, said once per app at info with the count, and the job-service verdict is not reported because it was never reached. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
…hedule-flow-deployment-switch
|
Rendered by an isolated at-tier review subagent and ADOPTED VERBATIM by the Downgrade-fuse reading, taken before adoption and ⛔ not from the agent's self-report: the reviewer's own transcript carries Head check: PR head at adoption is Contract reviewServed-tier: Worktree: ① Derived judgmentsRuling items, one by one (implemented / contradicted / absent):
Accept-set changes implied by the diff:
Public-surface changes:
Tests — strengthened or weakened: 10 removed Commands run in the worktree (exit codes captured to disk before reading):
CI at head (36 check-runs, read from
② Semver levelBy behaviour: MAJOR — a deployment that upgrades and does nothing loses every packaged time-triggered flow and every packaged ③ Boundary flags
Implemented-by: VERDICT: FAIL Generated by Claude Code |
…ork resolver Contract-review rework, both items in the review's scope. 1. Ruling G item 6 named three surfaces that must carry a DISTINCT reason; only two are delivered. Studio's only status door is `GET /automation/_status`, whose rows are `FlowRuntimeState` — a shape with no reason field — so a policy-disabled flow reaches it as `bound: false` and nothing more. Six published carriers stated the Studio leg as fact: the ADR-0087 semantic entry 18 (and its generated registry mirror), the changeset that ships as CHANGELOG, two docs pages and the `SCHEDULED_WORK_DISABLED_REASON` docblock that reaches `dist/*.d.ts`. Each now names only the audit and the CLI startup summary; the flows page says plainly what Studio cannot show, so nobody re-adds the claim. 2. `resolveScheduledWorkEnabled` / `resolveScheduledWorkPolicy` were pinned nowhere in `@objectstack/types`. Added the sibling switches' template (defaults OFF / truthy case-insensitively / anything else off), the three-row policy table, the bogus-posture throw, and a control proving the opt-IN shape differs from `resolveMultiOrgEnabled`'s opt-OUT one. Also: five comments cited "PR #17476 refuses a second" for the one-organization contract under `single`. #17476 touched only `skills/objectstack-data/**`. The claim is true; the enforcement is plugin-auth's org-create posture gate (`auth-manager.ts` `beforeCreateOrganization` → 403, pinned in `org-create-posture-gate.test.ts`). Citation corrected, claim unchanged. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
|
Rendered by an isolated at-tier review subagent and ADOPTED VERBATIM by the Downgrade-fuse reading, taken before adoption, ⛔ not from the agent's self-report: the reviewer's transcript carries
Contract reviewServed-tier: Worktree: ① Derived judgmentsREQUIRED item 1 (G6 Studio leg): trim is COMPLETE and TRUE.
REQUIRED item 2 ( Citation fix: TRUE, claim unchanged. PR #17476 (merged 2026-09-11T06:26Z) has exactly 3 files, all Ruling G, item by item, at
Accept-set changes (unchanged from the first head; re-derived): narrowing — every packaged time-triggered flow and packaged Public surface: Assertions, whole PR (merge-base..HEAD): removed Commands run in the worktree (exit codes captured to disk before reading): CI at head (40 check-runs read from ② Semver levelBy behaviour MAJOR (default-OFF narrowing of every packaged time-triggered flow and packaged ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Provenance —— 达档条款②复核 PASS,双载体已清。⛔ 但本 PR 仍然不能落地,原因与该判词无关
|
| 检 | 结果 |
|---|---|
| ① 席内达档条款②复核 PASS 在案 | ✅ 5673030422 |
② 双载体已清,--pair 机读 |
✅ exit 0 |
| ③ PR 全部 check 全绿(⛔ 非 required 子集) | 🔴 两红 |
⇒ ⛔ 不转 ready、⛔ 不入队、⛔ 不挂 auto-merge。 PASS 是契约判词,不是放行。
两红都在维护者地板上,且彼此独立:
check:type-source-resolution—— 复核实测签名:@objectstack/service-automation与@objectstack/trigger-schedule各有NEW dist-resolved type import(s) … @objectstack/types (via tsconfig.test.json)。base 提交d4554d4f5a的同一步 success ⇒ 是本 diff 引入的,⛔ 不是 base 红。闸门原文:「no self-serve remedy is left … Never widen that ledger and never widen arootDir— both are maintainer-only」。复核已核实返工没有绕过(全 PR 新增行里rootDir|"paths"|compilerOptions零命中,未碰任何scripts/**、tsconfig*、台账 json)。需要维护者按 A 把KNOWN_DIST_RESOLVED_TYPE_IMPORTS加上@objectstack/types。Check Changeset—— 按设计红,类别仍然吻合(闸门原文已在记录里逐字引用)。需要维护者确认那条待发布 note 可就地修正 —— 即上面那条至今不在案的确认。
一条无人认领的后续,已另立卡
G6 的 Studio 腿诚实地没有交付,而且现在任何会发布的句子都不再声称它交付了(复核逐个载体核过,树上零残留)。
但 objectui#9217(open,pm:blocked)正等着平台把 reason 送上线才能动 —— 而本仓没有任何卡指向那件事。⇒ 已立后续卡承接(FlowRuntimeState / _status 上一个由记录式拒绝填充的 reason,或一个审计路由),否则 #9217 无限期阻着。
Generated by Claude Code
|
Ruling: PR #18198 · maintainer 「18198 确认 + A」 · director seat · 2026-09-16T05:46Z Director seat, session ① 「确认」 — the
|
…entries Executes the maintainer ruling "18198 确认 + A" (relayed by the director seat at PR comment 5692650519). Ruling G item 1 puts the deployment-switch resolver in `packages/types`; items 2-3 put the gate and its reason in `service-automation` and `trigger-schedule`. That placement makes both packages resolve `@objectstack/types`' declarations through `dist/`, which `check:type-source-resolution` reds on, and the ruling chose option A — record it in the ledger — over B (`rootDir` / `paths`, measured TS6059), C and D. Two array lines, annotated in place as the registry's doc-block demands of a widening. The annotations state, rather than assume, which limb admits them: - NOT the #11490/#12511 re-baseline limb. Its condition 1 requires every admitted dep be reached only through a program the change ONBOARDED, and this change onboards nothing — `service-automation`'s `tsconfig.test.json` and both packages' `typecheck` scripts are byte-identical to the merge base. Both deps arrive through already-counted programs. - Admitted under the limb the gate's own failure text names instead: widening this ledger is maintainer-only, and the maintainer ruled. - Provenance is read off the instrument: `service-automation` was annotated `via tsconfig.test.json` (test program only); `trigger-schedule` carried no `(via ...)` at all, so it arrives through the MAIN `tsconfig.json`. The weaker of the two says so in place. - Numbers, which this registry requires or a widening is indistinguishable from a ratchet quietly reset: `--list` measures the repo and not this object, so it does not move (135 programs / 80 packages / 321 pairs / 19 clean, before and after, byte-identical output). The REGISTRY moves 61 entries / 319 pairs -> 61 entries / 321 pairs. Shrink-only from 321. Gate goes exit 1 -> exit 0; self-test OK both runs. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsThis is a SCOPED re-review of one delta, not a re-derivation of the PR. The review of record is 1. The delta does exactly what was ruled, and nothing more. 2. The annotation is honest about WHICH limb admits these two, and the claim survives the tree. The re-baseline limb's condition 1 (the doc-block over the registry) requires every admitted dep be reached ONLY through a program the change ONBOARDED. Measured, merge base vs head: 3. Shrink-only, audited in both directions, numbers stated where the doc-block demands them. The doc-block's rule for any widening is that the numbers are stated in place or the widening is indistinguishable from a ratchet quietly reset. Verified: 4. The gate fails before and passes after, on the same tree. 5. Other consequences checked: no model identifier in the added lines (the commit trailer is the model-free ② Semver levelUnchanged by the delta: ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Provenance — carriers cleared, and the basis for landing with one red, stated exactly
Record of record: comment Tier, verified per transcript rather than from the record's own line: the re-reviewer's transcript reads 88 of 88 Independence pair: Landing pre-check
|
…tack-ai#18243) Fixes objectstack-ai#18229 ## What changed `scripts/pm/check-half-states.mjs` only. H31 compares the two carriers of the `needs:contract-review` gate, and it took `prDeliversCard` — the wide body-first/branch-fallback delivery relation — as its population. That relation counts a `Part of #N` line, which declares MEMBERSHIP in what #N tracks rather than a delivery of it, so an epic tracker whose sub-PRs are gated correctly drew a row saying the gate was missing from the card half of a dual carrier. The row's remedy text is action-shaped, so acting on it hangs the gate on a card nothing will ever close — where the stroke that clears gates never arrives. Three edits: 1. **`bindingClosesCard(pr, n)`** — a new exported predicate, one read of `deliveryEvidence`'s existing grading, never a second keyword parser. It answers "does this PR CLOSE #n", which is the question the gate's clearing stroke rides. `part-of`, `part-of-inline` and `branch-name` all answer false. 2. **H31's own population filter** — the carrier comparison runs over the PRs `bindingClosesCard` accepts. Those rows are byte-identical to before. 3. **A DECLINED row for every other binding** — a weak-bound delivering PR whose carrier differs from the card's still produces a row, one that names both carriers and the binding it read, states that it declined to judge, and prescribes no write. A weak-bound PR whose carrier agrees produces nothing, exactly as before. `TRACKING_ANCHOR_LABEL` is extracted so the ruling-anchor state has one spelling across H13's exemption list and H31's new clause. ## Why this seam, and how the shared invariant survives The shared relation is NOT narrowed. `prDeliversCard`'s docblock forbids it (「⛔ Do not narrow it here to serve H8: that would make the live half invisible to the rows that exist to see it」), and H8's open side, H35's sibling resolver, `claimDelivery`, H53 and the `check-clause2-carriers` pairing all still read it wide. The narrowing is H31's own filter over the population that relation hands it, so the rows still agree about which PR delivers which card and differ only about which binding makes a CARRIER PAIR — a question only H31 asks. Both docblocks that state the invariant were updated to say so rather than left to imply the old thing. Silently dropping the weak bindings would have been objectstack-ai#4690 in this row's own uniform: a split that was never judged renders identically to a board whose two carriers agree, and this is the one row that can tell 「被剥」 from 「从未挂过」. Hence the declined row, and hence its scope: it speaks only where the carriers actually differ, because a standing row per tracker per sweep is the disease, not the cure. The declined row is deliberately NOT marked `UNJUDGED_MARKER` — that marker buys trim priority ahead of judged rows, and a decline must never sort ahead of a real carrier split in the same `gate` band. ## Measured on the live board Anchor objectstack-ai#9857's sweep at 2026-09-15T01:57Z, commit `b3b43b6`, run 34919049964, carries exactly one H31 row: objectstack-ai#14122 (`tracking`), naming open PR objectstack-ai#18212 (draft, via a `Part of` declaration). Replayed offline against the 9 open PRs and the 9 open cards those PRs name (the exact H31 candidate set — a card no open PR names can never reach the comparison): | card | `tracking` | delivering evidence | row before | row after | |---|---|---|---|---| | objectstack-ai#18122 | no | objectstack-ai#18238 closing-keyword | none | none | | objectstack-ai#17502 | no | objectstack-ai#18231 closing-keyword | none | none | | objectstack-ai#17598 | no | objectstack-ai#18230 closing-keyword | none | none | | objectstack-ai#15410 | no | objectstack-ai#18227 closing-keyword | none | none | | objectstack-ai#18202 | no | objectstack-ai#18212 closing-keyword | none | none | | objectstack-ai#14122 | **yes** | objectstack-ai#18212 **part-of** | action-shaped finding | **DECLINED row** | | objectstack-ai#17396 | no | objectstack-ai#18198 closing-keyword | none | none | | objectstack-ai#17356 | no | objectstack-ai#18131 closing-keyword | none | none | | objectstack-ai#17097 | no | objectstack-ai#18096 closing-keyword | none | none | One row changes, and it is the expected one. Every other delivering binding on the live board is a closing keyword, so nothing else moves. ## Tests `pnpm check:pm-half-states` (= `node scripts/pm/check-half-states.mjs --self-test`): **4042 cases pass** before, **4075 cases pass** after, exit 0 — 33 new cases. The pin and the control both land, per the card's own remedies: - pin: the `Part of`-only tracker still produces a row (⛔ not a silent skip), the row says `DECLINES to judge`, names the binding it read, explains why a ruling anchor can never clear a gate, and prescribes NOTHING; it is asserted to be neither the old action-shaped sentence nor a LOUD row nor an UNJUDGED-ranked one. - control: a closing-keyword binding whose card lacks the gate — the row STANDS, with the unchanged action-shaped sentence. - plus: agreement on a weak binding stays clean both ways; a weak-bound card without `tracking` still reports the split but without the ruling-anchor clause; an adjudicable split outranks a decline and names only the closing-bound PR. **Ablation** (one-off, restored; run from the committed fix). Mutating `bindingClosesCard` back to the pre-fix wide population (`deliveryEvidence(pr, n) !== null`) turns the self-test RED: ``` == anchor counts BEFORE == old-text occurrences: 1 / new-text occurrences: 0 mutation written == anchor counts AFTER == old-text occurrences: 0 / new-text occurrences: 1 HEAD blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811 MUT blob=906ac10ab7837ef5dc746f1bb3ccda8740e88c75 VERDICT ablation-exit=1 ✗ check-half-states self-test: 11 of 4075 case(s) failed. == restore leg == REST blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811 old-text occurrences after restore: 1 / new-text occurrences after restore: 0 git diff HEAD bytes: 0 ``` The mutation is proved on disk by the blob hash moving off the HEAD blob and by the two anchor occurrence counts flipping; the restore leg is proved by the blob hash returning to the HEAD blob and by an empty `git diff HEAD`, not by an exit code. There is no build step and no `dist/` for a repo-root `.mjs`, so the rebuild half of the ablation preflight does not apply here. **Gates** — derived with no paths, at head `e0a4efa`: ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 41 command(s) node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD_FILE -> Run reconciliation — 41 derived, 41 run, 0 NOT-MEASURED, 0 UNRUN. EXIT CODES — all 41 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them. ``` All 41 exited 0, each captured by redirect before any pipe. The gate script's own wiring is inside that set: `pnpm check:pm-half-states` is the package.json step lint.yml runs, and it is the script's `--self-test`. **Lint** — a declared narrowing, not the farm. `eslint scripts/pm/check-half-states.mjs --no-inline-config --format json` at head `e0a4efa`: **1 file linted, 0 errors, 0 warnings**, exit 0. The population is read from eslint's own config, not guessed — an `ESLint` instance over `git ls-files` reports `tracked=8674 in-scope=6755 ignored=1919 no-rules=0`. Invariance: this repo runs one `eslint.config.mjs` which enables type-aware linting for no file at all (every `parserOptions` in it is exactly `{ ecmaVersion: 'latest', sourceType: 'module' }`; zero `project`/`projectService` hits; the config says so itself at its `QUERY_OPTIONS_TEST_GLOBS` note), and that config is not in this diff — so no rule's verdict on a file this PR did not touch can move because of it. The repo-wide `pnpm lint` run is CI's. `skip-changeset`: `scripts/pm/**` is PM tooling and ships in no package's `files[]`, so nothing published moves. ## Acceptance notes - `scripts/pm/check-half-states.mjs` is non-governed PM tooling, so this is in-seat review. `.github/workflows/half-state-patrol.yml` and anchor objectstack-ai#9857 are untouched: the workflow runs the script from `main`, so the fix reaches the anchor at the first scheduled sweep after landing. - objectstack-ai#18214 is the sibling shape one file over — the clause-② dual carrier that `scripts/pm/check-clause2-carriers.mjs --pair` demands on an epic tracker reached through a `Part of` line. It is out of scope here and remains open. `bindingClosesCard` is exported for it: that file already imports `deliveryEvidence` and `prDeliversCard` from this one, so the fix there is an import rather than a second copy of the predicate. - noted, not filed: the sibling install of this script in `objectui` (`scripts/pm/check-half-states.mjs`) has already drifted from this copy (different md5 at `b3b43b6`), while this file's header describes the pair as copied VERBATIM. No gate can hold two repos equal, so the wording is an aspiration rather than a claim — carrier: the next dev dispatched on a cross-repo patrol card. --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ait, instead of parking the run forever (objectstack-ai#18370) Fixes objectstack-ai#18179 The objectstack-ai#17928 hole reached through a different door. Under `eventType: 'timer'` the contract requires a non-blank `timerDuration`, but the key is `z.string()` and no schema can evaluate the string: `'not-a-duration'`, `'1 hour'`, `'P'`, `'PT0S'`, `'PT0H0M0S'`, `'0'` and `'-5'` are all documents that **save**, and `parseIsoDuration` answers `undefined` for every one of them — exactly as it did for the absent key. This PR makes the executor refuse them loudly instead of parking the run forever while reporting success. ## Premise re-check on this branch's base, `b57671edc1` — every cited line holds The dispatch measured at `1411cf2c6f`; this branch forked at `b57671edc1` (`origin/main` is still `b57671edc1` as this PR opens). Re-measured here rather than carried: | cited | dispatch | measured on `b57671edc1` | |---|---|---| | `parseIsoDuration` declaration | `:526` | `:526` OK | | no deadline computed (`durationMs && durationMs > 0`) | `:298` | `:298` OK | | no `waitUntil` persisted (`const output = at ? …`) | `:304` | `:304` OK | | no job armed (`if (job && runId != null && at)`) | `:307` | `:307` OK | | the no-job-service fallback (`else if (!job)`) | `:329` | `:329` OK | | returns `{ success: true, suspend: true }` | `:337` | `:337` OK | | the tree pins it as expected | `wait-node.test.ts:332` | `:332` OK | ## The state the old path left behind With a job service **answering**, neither branch could run: the arming guard fails on the deadline, and the `else if (!job)` fallback fires only when the job service is **missing**. Control fell straight to the suspending return, whose comment points at recovery through "a later boot's re-arm pass, when the deadline was persisted" — and `:304` had just established that it was not. Measured through a real `engine.execute()` run, not read off the source: | channel | before | after | |---|---|---| | `engine.execute()` | `{ success: true, suspend: true }`, status `paused` | run **fails**, node status `failure` | | suspended runs | one, parked forever | none — the run never suspends | | persisted `waitUntil` | absent, so the cold-boot re-arm pass is blind to the run | nothing persisted, because nothing parks | | scheduled jobs | none, with a job service **answering** | none, and the run is refused instead | | log lines during the run | **0 at any level** | one `warn` naming the node, the value and the remedy | | `errorClass` | n/a — it reported success | `'guard'`, so a `fault` edge cannot route it | A warning-only fix would have moved the last row and nothing else; the run would still park forever. ## What lands **`wait-node.ts` — the timer branch refuses before it suspends.** When `parseIsoDuration` answers `undefined` (which already covers zero and negative), the node emits a `warn` and returns `refuseNode(...)`, mirroring what PR objectstack-ai#18175 (`cb1f27434b`, on `main`) already did for a stored absent block. The refusal is `guard`-class for the same reason: the metadata is wrong, re-running changes nothing, and a `fault` edge must not be able to route a metadata defect into a handler that then reports success. - **`warn`, not `error`**, by AGENTS.md's degradation rule and for the same reason the block-less refusal above it is `warn`: the failure is handed to the **caller** — the run fails and says so — so nothing looks normal from the outside and this is not a durability degradation. - **The offending value travels with the complaint**, quoted through `JSON.stringify` rather than spliced raw. `timerDuration` is author-controlled foreign text arriving on a log record, and a value carrying a newline would split the alarm into physical lines of which only the first carries its level — the objectstack-ai#5737 hazard, arriving from the authoring side instead of the driver side. Pinned by its own test. - **Zero and negative are the same verdict, deliberately not a separate one.** `'PT0S'` is not a short wait; it is a deadline already past, and it parks just as permanently. - **`output` is now unconditional in that branch.** The old `at ? { output: … } : {}` spread existed so `output` would be absent rather than a present key holding `undefined` when no deadline was computed. That distinction still matters and is still pinned (in `absent-config-node-characterization.test.ts`, on the signal branch); what is gone is the arm that reached it. The two degraded returns still carry the deadline — they lose the auto-resume, never the `waitUntil` a later boot re-arms from. **`wait-node.test.ts` — the pin that asserted today's behaviour as expected moves, and the moved pin is the evidence.** `:332` ("cancels nothing for a timer wait that armed no job") kept its subject — teardown — and swapped its fixture: it used `timerDuration: 'not-a-duration'`, and its own comment named that fixture as the repro for a residual with no card. That card is objectstack-ai#18179 and this PR discharges it, so the fixture moved to a job service whose `schedule` throws, the one route left to a degraded `timer:pause` correlation on a host that **has** a job service. A new block pins the reversal on every channel in the table above, across all seven unusable spellings, with a `PT1H` control that still suspends, arms the one-shot and persists the deadline. ## Scope — and the half deliberately NOT decided here The queued scope was the runtime half only, and that is all this PR contains. A **format refinement on `timerDuration`** narrows a published accept set; decision batch objectstack-ai#127 item 5 requires the block and the key and 「⛔ does not rule on the key's lexical validity」, so there is no ruling behind a narrowing. No file under `packages/spec/src/automation/**` is touched: `builtin-node-config.zod.ts`, `flow.zod.ts`, `node-executor.zod.ts` and `schemaless-node-config.zod.ts` are all unchanged, and every one of the seven fixtures above still parses clean — which the new premise leg asserts explicitly, so this PR's own tests would go red if the accept set ever moved underneath them. ## Clause-②: no Clause-②: no — this PR widens nothing. The accept set does not move and no published surface grows: a run that used to report `success: true` forever is refused loudly instead. Nothing new is exported: the refusal reuses `refuseNode` from `guard-refusal.ts`, which this file already imported; no new error code, no new exported helper, no new schema refinement. `packages/services/service-automation`'s `exports` map is byte-identical. ## Evidence Every command below was run on this branch, most of them through `scripts/pm/os-verify-lock.sh`. - **Dependency-closure build** — `pnpm --workspace-concurrency=2 --filter '@objectstack/service-automation^...' build` :: exit 0. - **Package tests** — `pnpm --filter @objectstack/service-automation test` :: exit 0 — **136 files, 1625 tests passed**. - **Package typecheck** — `pnpm --filter @objectstack/service-automation typecheck` :: exit 0, including `check:test-typecheck` (0 files / 0 errors held in the debt ledger). - **Gate families** — derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no hand-written path list), run, then reconciled with `--ran`: **62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN**, all carrying recorded exit codes. Five of them first answered `exit 3 PREREQUISITE NOT MET`; they were re-run to a real `exit 0` after `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72/72 tasks) rather than recorded as passes. - **Repo-wide lint, not a narrowed one** — `pnpm lint` (`eslint . --no-inline-config`) :: exit 0 over the whole population, so no narrowing needs defending. - **Reverse verification** — the fix was committed first, then `git restore --source=b57671edc1` put the pre-fix file back (tree only, never the index; 0 staged paths confirmed). On-disk landing proven by anchor counts, not by the editor's exit code: the `REVERSAL (objectstack-ai#18179)` marker went 1 -> 0 and the old `const at = durationMs && durationMs > 0` anchor 0 -> 1, and the mutated blob hash equalled the base blob hash exactly. **`src/builtin/wait-node.test.ts` then failed: 8 failed | 28 passed (36).** Restored with `git checkout HEAD -- THE_FILE`; the restored blob hash equalled the HEAD blob hash, `git diff HEAD` was empty, and the re-run was **36 passed (36)**. The subject resolves through a relative import inside its own package, so no `dist` leg applies to this ablation. The ablation script carried a `trap … EXIT INT TERM` restore on an absolute path throughout. - Direction, predicted before the run and observed: **turns red**, and only the new block did. The premise leg, the teardown pin and the `PT1H` control all stayed green under the ablation — they are not about the fix. - **Changeset — measured, not assumed.** `@objectstack/service-automation` is published and its `files` is `["dist","README.md","CHANGELOG.md"]` (no `src`). After building the package: the subject text `is not a usable wait` reaches **both** published bundles (`dist/index.js` 2 hits, `dist/index.cjs` 2 hits); positive control `a wait node must declare what resumes it` (text that already ships) 1 hit in each; negative controls `REFUSED, not parked` and `scheduleThrows` (both test-only) 0 hits in each. => it publishes => `.changeset/18179-timer-duration-unusable-refusal.md`, `patch`, named for the card so it cannot collide with another PR's pending note. - **Control bytes** — `grep -naP` over the changed files for the C0/C1 range: no match, beside `pnpm check:nul-bytes` :: exit 0. ## Declared file face - `packages/services/service-automation/src/builtin/wait-node.ts` — the timer branch - `packages/services/service-automation/src/builtin/wait-node.test.ts` — the moved pin plus the reversal block - `.changeset/18179-timer-duration-unusable-refusal.md` Three files, and nothing else. In particular **not** `packages/services/service-automation/package.json` or `vitest.config.ts`, which PR objectstack-ai#18198 owns right now — the fix needed neither, and no file under `src/builtin/` overlaps any of the three concurrent faces named at dispatch. ## Acceptance notes Recorded here rather than filed, per the scope rules. **1. The sibling that rides with objectstack-ai#18179 — and a correction to how it was described.** The dispatch (and the card) say that `{ eventType: 'signal' }` with no `signalName` makes the executor "correlate on `undefined`". Re-measured on `b57671edc1`, it does not: `wait-node.ts:342` on `b57671edc1` reads ``const signal = String(wec.signalName ?? `wait:${node.id}`)``, so the pause carries a synthesized `wait:pause` correlation. That difference is load-bearing, and it is why the sibling is **not** discharged inside scope 1: - A timer wait declares a deadline and then has no mechanism that can ever arrive. A signal / webhook / manual / condition wait declares an **external** resumer, and the documented external route still works — `resumeAuthority: 'any'`, the REST resume door objectstack-ai#3801 deliberately leaves open for `wait`, and `resume(runId)` — which the existing pin "cancels nothing for a signal wait" exercises end to end. The run is parked but reachable, not parked and unreachable. - Refusing an absent `signalName` would bite `eventType: 'manual'`, where absence is the normal authoring shape for an operator-resumed pause, and `'condition'`. Deciding which of the four must carry the key is a question about the contract's accept set — scope 2 — not a runtime bug the runtime may settle by itself. Reported rather than widened, as the dispatch directs. ⛔ No card filed for it; this PR is its carrier. - One narrower shape found while measuring it, recorded and **not** acted on: `signalName: ''` passes `z.string()` and survives `??` (which catches only nullish), so `{ eventType: 'signal', signalName: '' }` suspends with an **empty** correlation. Same scope-2 question, same answer: reported, not decided. **2. `runId == null` on the timer branch is still a silent degrade.** With a job service present but no run id, neither `if (job && runId != null)` nor `else if (!job)` runs, so the node suspends with no line logged. Re-measured, it is not reachable through the engine: `engine.ts:10600` sets `variables.set('$runId', runId)` unconditionally in the seeding path. Not a class (a) finding, therefore noted, not filed. Successor: none — there is no PR or person queued on this file after this one; it is recorded so the next reader of this branch does not have to re-derive it. Authored by the `os-dev` round of session `session_01KB5PFtxuy1x3dcR5gxudx6`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
#18198 wrote that `GET /automation/_status` "has no field to say why", so a policy-disabled flow "is indistinguishable there" from one whose trigger is missing. This branch adds exactly that field, so the paragraph is false on its own head — the same ground on which `packages/types/src/env.ts`'s docblock was corrected in this landing. One prose carrier was corrected and its twin was missed. Corrected the same way, and no further: the reason reaches the WIRE, read from the recorded refusal; whether a console renders it as a distinct state is that console's own change, ⛔ not claimed here. Swept for siblings: `no field to say why` / `indistinguishable there` hit this file only (the other hits are core/security, objectql, rest and spec, all unrelated), against 19 files under content/ mentioning `_status` — and `content/docs/releases/**` is release-owned and untouched. One residual, not a class. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
… not armed (objectstack-ai#18635) Fixes objectstack-ai#18235 Clause-②: yes `FlowRuntimeState` gains `reason` — the optional sentence saying WHY a flow is not armed — and the automation engine populates it, so `GET /automation/_status` can finally tell a policy-disabled flow apart from a broken binding. ## What was missing Ruling G item 6 on objectstack-ai#17396, verbatim: > **OFF**: neither trigger arms any flow. Every such flow is listed in `getTriggerBindingAudit()`, the CLI startup summary and Studio with a DISTINCT reason — disabled by deployment policy — ⛔ never as "binding failed". PR objectstack-ai#18198 delivered the first two surfaces and trimmed every published sentence that claimed the third, so nothing published was false. What was missing is that the third surface could not be BUILT: Studio's only status door is `GET /automation/_status` → `getFlowRuntimeStates()` → `FlowRuntimeState`, and that shape had no field a reason could travel in. Re-measured on `origin/main` at `7299b945a2` before writing a line: ``` packages/spec/src/contracts/automation-service.ts:499-517 interface FlowRuntimeState name / enabled / bound / status? / triggerType? / object? DARK occurrences of `reason` in that interface block 0 LIT occurrences of `bound` in the same block 2 ``` ⇒ on the wire a policy-disabled flow was `enabled: true, bound: false, triggerType: 'schedule'` — byte-identical to one whose trigger is missing, which is the reading ruled item 6 forbids. ## The three open questions, answered by measurement **1. Does the runtime producer move in this PR? YES.** A declared-but-never-populated key is the ADR-0049 shape this repo files findings about, and the honesty rule is stricter still here: `SCHEDULED_WORK_DISABLED_REASON`'s own docblock says ⛔ do not write that Studio reports this reason "until a reason reaches that wire shape: declared is not delivered". Shipping the key alone would leave that sentence exactly as false as it is today, and objectui#9217 exactly as blocked. The producer is `AutomationEngine.getFlowRuntimeStates()` (`packages/services/service-automation/src/engine.ts`), NOT `packages/runtime/src/domains/automation.ts`: that door reads the rows through the contract type and answers `success({ flows, total })` — a verbatim pass-through with no field picking, so it needed no edit, only a pin. **2. Optional or required? OPTIONAL, measured.** Producer set: the engine, plus the test doubles in `packages/runtime` (`domain-handler-registry.test.ts`, `http-dispatcher.test.ts`, `automation-run-read-permission-gate.test.ts`), `packages/cli` (`serve-automation-summary.test.ts`, `serve-automation-shadowing.test.ts`) and `packages/qa/dogfood` — every one of them writes `{ name, enabled, bound }` at minimum. `AutomationEngine implements IAutomationService`, so a required key would also have turned its inline return type red. And semantically a required key would demand a reason from rows that have none: a bound flow, a disabled flow, a manual flow. **3. Closed union or free string? FREE STRING, matching what already ships.** `getTriggerBindingAudit()` answers `{ flowName, triggerType, reason: string }` — a human sentence from a three-branch vocabulary, the policy branch being the shared `SCHEDULED_WORK_DISABLED_REASON` constant; the CLI startup summary prints that string. `status` and `triggerType` beside the new key are free `string`s too. objectui#9217's acceptance item 3 says it outright: "The exact reason-code shape is the platform's … if the platform ships a string, render the string." ⛔ No fourth vocabulary was invented. ## How the vocabulary is held to ONE Both doors now read one private `describeUnboundReason()` on the engine — same eligibility rule (enabled, unbound, declares a trigger), same three branches, same policy sentence read from the RECORDED refusal (`policyDisabledFlows`) and ⛔ never re-derived from a live `resolveScheduledWorkPolicy()` call. `_status` is served on demand, arbitrarily long after the bind — a strictly worse case for re-derivation than the audit's two boot-time callers, and re-derivation is the exact defect objectstack-ai#18198's own round caught and fixed. Pinned as an identity between the two doors rather than as two copies of the expected text, so a future edit to either wording fails instead of forking the vocabulary. ## File surface | path | what | |:---|:---| | `packages/spec/src/contracts/automation-service.ts` | `FlowRuntimeState.reason?: string` + its docblock — the card's subject | | `packages/services/service-automation/src/engine.ts` | the producer: `describeUnboundReason()`, read by `getFlowRuntimeStates()` and `getTriggerBindingAudit()` | | `packages/types/src/env.ts` | docblock only — this PR makes `SCHEDULED_WORK_DISABLED_REASON`'s "Studio is NOT one of them" paragraph false, so it is corrected in the same landing (wire yes, rendering still objectui#9217) | | `content/docs/automation/flows.mdx` | docs prose only — its callout said the status door "has no field to say why", which this PR makes false; corrected on the same ground as `env.ts` (patch round) | | `packages/spec/src/contracts/automation-service.test.ts` | type-level identity pins + the runtime probe / lit / dark controls | | `packages/services/service-automation/src/engine.test.ts` | producer pins: policy row, anti-drift identity, record-not-environment, bind failure, DARK absence | | `packages/runtime/src/domain-handler-registry.test.ts` | the wire pin: `_status` carries `reason` through, and omits it where the producer wrote none | | `.changeset/18235-flow-runtime-state-reason.md` | `@objectstack/spec` minor, `@objectstack/service-automation` minor, `@objectstack/types` patch; `Clause-②: yes (widening)` | ⛔ No generated artifact moved — see the DARK control below. Nothing under `packages/spec/src/ui/`, `packages/spec/src/migrations/**` or `packages/spec/scripts/` is touched. ## Red before green `check:generated` is NOT a gate for this change, and that is a measurement, not an assumption: run on the clean tree it was green (15/15), and run again with the new field in place and a rebuilt `dist` — before regenerating anything — it was still green (15/15), because no spec artifact records interface MEMBERS (`api-surface/contracts.json` records `"FlowRuntimeState (interface)"`, `export-origins/contracts.json` records its origin, `declaration-map/` has no `contracts.json` at all, and `api-surface-signatures.json` has zero `FlowRuntimeState` hits). A gate never observed failing is not known to be a gate — so the gate that WAS observed failing is `check:test-typecheck` (CI's required `TypeScript Type Check` job), plus the producer pins: **LEG 1 — the producer stops publishing the key.** Mutation written to disk and proved there (anchor occurrences 1 → 0, blob `ec75d9cf` → `29716a38`): ``` Tests 4 failed | 154 passed (158) × OFF: the status row names the policy, and ⛔ NOT a binding failure × the two doors answer the SAME sentence for the same flow — one computation, no drift × the status row reports what HAPPENED, not what the environment says when it is read × ON: a genuine bind failure reads as one on the status row too ``` ⭐ LIT control inside the same run: the 154 that still pass include every pre-existing ruling-G audit pin — the sibling door is untouched — and the DARK absence test passes in the mutated tree too, which is what a well-formed absence assertion must do. **LEG 2 — the contract stops declaring the key** (anchor 1 → 0, blob `8d9231d4` → `0f89941c`): ``` check:test-typecheck: 2 problem(s) • src/contracts/automation-service.test.ts: 4 type error(s) … ARRIVED: TS2339: Property 'reason' does not exist on type 'FlowRuntimeState'. • src/contracts/automation-service.test.ts: 1 type error(s) … ARRIVED: TS2353: Object literal may only specify known properties, and 'reason' does not exist in type 'FlowRuntimeState'. LEG2 TYPECHECK EXIT=1 ```⚠️ Reported as observed, not as the template predicts: in LEG 2 the spec UNIT suite stayed green (17/17). The docblock pin reads the interface's comment, which the mutation left in place; it is `check:test-typecheck` that covers the key's existence. Both legs restored from `HEAD` under a `trap`, and the restore is proved by blob identity (`ec75d9cf` / `8d9231d4` both back) plus an empty `git diff HEAD`, not by an exit code. ## Evidence - `pnpm --filter @objectstack/spec check:generated` — **15/15 up to date**, clean tree and again at the final tree. - `check:api-surface`, `check:docs`, `check:authorable-surface`, `check:export-origins`, `check:declaration-map`, `check:browser-reachable-entries`, `check:dual-source-exports`, `check:entry-nameability`, `check:exported-any`, `check:liveness`, `check:empty-state` — all green.⚠️ Five of them first answered PREREQUISITE NOT MET (`packages/spec/dist` older than `src`), which is NOT MEASURED rather than red; re-run after `pnpm --filter @objectstack/spec build`, all five exit 0. - Typecheck: `@objectstack/spec`, `@objectstack/service-automation`, `@objectstack/types`, `@objectstack/runtime` — all 0. - Tests: spec **484 files / 13821 passed**; service-automation **136 / 1639**; runtime **264 / 3655**; types **22 / 683**. - `node scripts/pm/check-widening-tells.mjs --declaration yes --diff PRDIFF` — exit 0. - Gate reconciliation: `node scripts/pm/dispatch-gates.mjs --ran` — **83 derived, 81 run, 2 NOT MEASURED**. The two are `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3 (PREREQUISITE NOT MET: they need a whole-workspace build closure) — declared to CI, not claimed as passes. - `pnpm check:nul-bytes` green, plus a direct control-character sweep of all seven touched files: zero hits. ## ⭐ DARK control - No OTHER interface in `contracts/**` gained a member: the whole diff under `packages/spec/src/contracts/` is one `reason?: string;` line plus its docblock, inside the `FlowRuntimeState` block. - No published payload outside `FlowRuntimeState` moved: every generated artifact is byte-unchanged (`git status` after the field + rebuild showed only source files), and `check:generated` agrees at 15/15. - On the wire, a row the producer left without a reason carries no `reason` key at all — asserted on both the engine row and the `_status` response. ## Patch round — the at-tier review's one FAIL, discharged The isolated at-tier contract review returned FAIL on one residual and PASS on everything else. The residual: `content/docs/automation/flows.mdx` still carried objectstack-ai#18198's sentence that `GET /automation/_status` "has no field to say why", so a policy-disabled flow "is indistinguishable there" — false the moment this PR adds the field, and false on exactly the ground used to correct `packages/types/src/env.ts` in the same landing. One prose carrier had been corrected and its twin missed. I agree with the FAIL on the merits; no objection recorded. Corrected the same way and no further: the reason reaches the WIRE, read from the recorded refusal, and ⛔ reaching the wire is still not being *rendered* — whether a console shows it as a distinct state is that console's own change, which this page does not claim. Own sweep, independent of the one handed to me: `no field to say why` / `indistinguishable there` hits this file only — the other hits are in `core/security`, `objectql`, `rest` and `spec`, all unrelated — against a lit control of 19 files under `content/` that mention `_status`, and `content/docs/releases/**` (the only other `automation/_status` mention) is release-owned and untouched. ⇒ one residual, not a class. No pin reads this paragraph: the four tests and scripts that name this page cite other sections, so no test is owed. Re-derived after the docs commit: 105 gate families (22 newly derived by the `content/` path), reconciled by `dispatch-gates --ran` at 105 accounted / 103 run / 2 NOT MEASURED / 0 unrun. All 22 new ones green, including `check:skill-examples` (258 prose examples type-check across 3 surfaces), `check:docs-transcript-drift`, `check:doc-security-posture`, `check:corpus-claim-drift`, `check:doc-anchors` and `check:docs-audit-scope`; three of them first answered PREREQUISITE NOT MET on an unbuilt `@objectstack/lint` / `@objectstack/client-react` and were re-run after building those closures. `docs-audit/check-affected-docs.mjs`, `check:doc-authoring`, `check:keyed-text-bounds`, `check:nul-bytes` and `check:generated` (15/15) re-run green on the new head. Tests were not re-run: the patch-round diff is one MDX paragraph plus one changeset sentence, and the only gate that compiles docs prose (`check:skill-examples`) is in the green list above. ## Acceptance notes - **noted, not filed:** `packages/cli/src/commands/serve.ts`'s startup banner re-declares the `getFlowRuntimeStates()` row shape inline and does not name `reason`; it reads the audit for its unbound section, so nothing is wrong today and the banner needs no change. Carrier: whoever next touches that banner's row type. - **to file (class (a), reproducible defect; dedupe words: `cross-package-test-inputs` · `init-created-files-summary` · `packages/spec/dist walk` · `objectstack-ai#15565 tree-scoped walk` · `gate vacuous without dist`):** `pnpm check:cross-package-test-inputs` exits 1 on any tree where `packages/spec/dist` is BUILT — `packages/cli/test/init-created-files-summary.e2e.test.ts` descends `packages/spec/dist/` and no declared glob reaches inside it. Proven not to belong to this PR: with this branch's entire diff reverted in the working tree the gate still exits 1 with the identical finding, and on a checkout with no `packages/spec/dist` it exits 0 — i.e. it passes vacuously wherever the lint job has not built spec. Filing is the seat's act; this PR does not touch it.⚠️ This branch is 5 commits behind `origin/main` at the time of writing; none of those commits touches any of the seven paths above (verified with `git log BASE..origin/main -- THE-SEVEN-PATHS`, empty). The merge queue rebuilds the PR as merged onto current `main` and re-runs the required set there. Authored with Claude Code in session `session_01JbZnqu8bt6YqfJsr9vaFb3` (both the delivery round and this patch round). --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…y face already had (objectstack-ai#18319) Part of objectstack-ai#17534 — this lands the ruled change on the authoring contract, the refusal, both scaffolders and the ADR-0087 entry, plus (2026-09-20, under ruling batch objectstack-ai#200 item 3) the merge of `origin/main` and ruling Q3-A component 2: the ADR-0130 D4 record-keeping block.⚠️ The PR is therefore a **GOVERNED** diff (`docs/adr/**`, tier H, 1 of 95 paths, under the 5000-line line): it lands by the maintainer's own merge or an authorized APPROVED review from a `GOVERNED_APPROVERS` account, after the at-tier contract review. `Part of`, not `Fixes`: objectstack-ai#17534 closes when the seat closes it. Clause-②: yes — `manifest.id`'s accept set narrows (the reverse-domain pattern is enforced where a bare string was accepted); the carrier of record is on the card (5691010940). ## What the ruling asked for, and what is here **1. One declaration, referenced from both sites.** `MANIFEST_ID_PATTERN` is exported from `packages/spec/src/kernel/manifest.zod.ts`; `ManifestSchema.id` and `PackageSchema.manifestId` both reference it. `manifest.test.ts` holds a per-field verdict table across the two, so a future edit to either has to break a test to drift. The `.describe()` moves to "must match reverse-domain notation" in the same commit as the regex — objectstack-ai#17921's self-contained half, the wording constraint, is satisfied by construction. **2. Refusal with a remedy (objectstack-ai#4001).** The message names the key, echoes the value, shows both documented examples, and prescribes a repair it has already checked against the pattern. Built on the existing `formatSuggestion` from `shared/suggestions.zod.ts` — no new message builder. ``` manifest.id: Invalid package id 'blank' on `manifest.id`. Expected reverse-domain notation ('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments; hyphens allowed inside a segment, underscores are not. Did you mean 'com.example.blank'? ``` The suggestion arm is conditional on purpose: a bare word gets `com.example.NAME`; a value that already carries a dot gets its underscores hyphenated in place; a value nothing mechanical rescues gets no suggestion rather than a wrong one. Every candidate is tested against the pattern before it is offered — objectstack-ai#17461's lesson applied to the remedy, not only to the examples. **The two `@example`s were verified first, as the ruling required.** `com.steedos.crm` and `org.apache.superset` both match. They are no longer a comment: `MANIFEST_ID_EXAMPLES` is the one list the TSDoc, the refusal and the pin all read, so an example that fails its own rule is now a red test rather than bad advice. **3. Both scaffolders derive a conforming id.** The bundled template ships `com.example.blank`, and `create-objectstack` interpolates `deriveManifestId(projectName)`. `os init` derives from the project name too. **The second half prerequisite 4 named is real and is fixed here:** `os init my-app` sanitizes the namespace to `my_app` and every template interpolated that namespace into the id, producing `com.example.my_app` — refused by this very schema. One project name, two identifiers, contradictory rules; neither can be read off the other. Pinned from both sides against the imported `MANIFEST_ID_PATTERN`, never a restated regex. **4. Migration.** ADR-0087 **semantic** entry `manifest-id-reverse-domain-required` — a structured TODO, no automatic rename, because the value IS the identity: the registry addresses a package by `manifest_id`, an installed row is keyed on it and a dependent declares it. The changeset carries the matching disposition marker and ships `minor` under the launch-window convention. Sub-question (1) is answered in the entry's own `reason` from the TSDoc's words: the rule is "unique across the entire ecosystem", so a package that is never published is still named for the ecosystem it may join. ## The former blocker — landed, gate green The 21 `packages/qa` manifest ids (and, after the maintainer widened the scope on 2026-09-15, the further fixtures across `packages/cli`, `runtime`, `plugins`, `spec`, `objectql`, `lint`, `rest`) were renamed inside this PR under the 2026-09-15 rulings on objectstack-ai#17534; at head `1d4c99bcbd` (the F1 rounds after the at-tier reviews 5751237820 and 5751666135; round 3 PASS 5751970203) **zero literal `manifest.id` values the pattern refuses remain in the tree** outside the declared residues — the install-door residual pin `packages/runtime/src/domain-handler-registry.test.ts:591` (`pkg-a`, kept on purpose: it pins the HTTP install door that still reads the id positionally), this PR's own changeset FROM → TO examples (a migration note must show the refused spelling), and the spec template-manifest negative controls — measured by the round-3 reviewer's eight-leg sweep (brace-key, JSON key, window, `manifestId`, `const manifest`, aliased body, `id: CONSTANT`, factory body, plus an inverse constant leg) over 9070 tracked files through the BUILT `ManifestSchema.shape.id` with a 12-shape injected control, 12/12 caught (record 5751970203; the dev's own sweeps are on objectstack-ai#17534, 5751523635 and the round after it); the `Dogfood Regression Gate` (1/3, 2/3, 3/3) is green on CI. The 2026-09-20 merge had carried 13 `crm_core` / `crm_service` ids from `main` into `packages/lint/src/validate-translation-references.test.ts`, and seven published guidance sites (`packages/spec/prompts/create-new-project.md`, five `content/docs` pages, `service-package/README.md`) plus two platform-checklist items and one `create-objectstack` fixture were still teaching refused ids; all renamed in the F1 round (44 occurrences over 10 files). The merge of `origin/main` (2026-09-20, `scripts/pm/os-regen-merge.sh`, merge commit only) forced two pin repairs — `packages/spec/src/api/package-api.test.ts` and `packages/cli/src/utils/nav-contribution-groups.package-id.test.ts` landed on `main` while this branch was open and measured the accept set this branch narrows; each is rewritten to the measured reality with a lit control, `MANIFEST_ID_PATTERN` untouched. The ADR-0130 D4 block records the entry door order (DOOR 1, the schema, refuses an empty `manifest.id` as `INVALID_ARTIFACT_PACKAGE_ENTRY` / 422; DOOR 2, `artifactPackageId`, never runs for that spelling), cites `MANIFEST_ID_PATTERN` and `PackageSchema.manifestId`, and names the fail-OPEN → fail-CLOSED direction and its population — an addition to D4, not a reversal. ## In-package fixtures repaired here The same defect existed inside the packages this card does own, and those are fixed — mechanical, single-literal, and each fixture keeps pinning exactly what it pinned before: three bare ids in `packages/spec` tests (`p`, `acme`), four in `packages/cli` tests (`todo`, `demo`, `bad`, `a`/`b`), and the five **golden corpus** entries in `packages/cli/src/lint/corpus.ts`. That last one is not a fixture: it is the worked-example set the metadata generator imitates, and it was teaching bare-word ids. `packages/create-objectstack/vitest.config.ts` gains an anchored source alias for the one spec subpath its tests now import, because `pnpm check:test-source-alias` correctly refuses a pin whose verdict is a function of build state. ## Not touched, deliberately `docs/adr/0087-metadata-protocol-upgrade-contract.md` — the registration this gate wants is the changeset's disposition marker plus the entry file, both present. Editing the ADR would make this a governed-surface diff for no gain. Also untouched: every file in open PR objectstack-ai#18198, `packages/spec/src/shared/retired-key.ts`, `packages/spec/src/ui/chart.zod.ts`. ## Verification Run at `d0d859edbc` (the original round). Re-run at the final head `6783718d6a` (2026-09-20): 137 derived gate families, 137 run, 0 NOT-MEASURED; runtime 270 files / 3734 passed; spec 502 files / 14733 passed, typecheck and `check:generated` (15/15) green; cli unit 221 files / 3127 passed and integration shards 26/207 + 25/220 passed; create-objectstack 16 files / 215 passed; `pnpm lint` repo-wide exit 0; ADR anchor/symbol/link gates and `check-adr-0087-registration --base origin/main` exit 0. The readings of record are in the `os-dev-report` on objectstack-ai#17534 (5751052645). | what | result | |:--|:--| | `node scripts/pm/dispatch-gates.mjs --ran` | **127 derived families, 127 run, 0 NOT-MEASURED, 0 UNRUN**, every one recorded with its exit code, all zero | | `pnpm --filter @objectstack/spec test` | 480 files, **13715 passed**, 1 skipped | | `pnpm --filter @objectstack/spec typecheck` | pass | | `pnpm --filter @objectstack/cli exec vitest run --project unit` | 209 files, **2988 passed** | | `pnpm --filter @objectstack/cli typecheck` | pass | | `pnpm --filter create-objectstack test` | 16 files, **215 passed** | | `pnpm --filter create-objectstack typecheck` | pass | | `pnpm lint` (repo-wide, `eslint . --no-inline-config`) | exit 0 — the full population, not a narrowing | | `pnpm --filter @objectstack/spec check:generated` | all 15 artifacts current after regenerating `api-surface`, `export-origins`, `content/docs/references` | | `packages/qa` dogfood tier | **NOT RUN — known red**, the 21 files above | `packages/cli`'s integration tier is declared to CI: the diff touches no spawn entry, no `bin/`, no `test/helpers/serve-process.ts` and no driver or kernel boot path. Original author: Claude Code session `session_01KB5PFtxuy1x3dcR5gxudx6` (the implementation rounds); merge, ADR-0130 D4 block and pin repairs by the director seat's round, `session_012GcsUbuqFGBibkEDMRC1eE`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17396
Implements ruling G (
5642381255, director seat, decision batch #116 item 4) and its addendum (5642795312, batch #118). ⛔ The card body's options A/B/C/D and the later E are superseded; nothing here re-opens which option.What this is
A deployment-level environment variable —
OS_AUTOMATION_SCHEDULED_WORK_ENABLED— decides whether this deployment runs package-authored scheduled work: time-triggered flows (type: 'schedule'with aconfig.schedulecadence, and thetimeRelativesweep) and packageddefineJobcron jobs. It is read at boot besideresolveTenancyPostureinpackages/types/src/env.ts. It is ⛔ not a metadata concept and ⛔ not a new spec key.OFF by default, in every posture and every kernel. Whether a clock-driven workload is affordable is a fact about the deployment — its database, its tenants, its budget — that no package author can know, and a metadata key would ask them to.
The three bind states, resolved once by
resolveScheduledWorkPolicy()so both triggers, the engine's audit, the packaged-job loop andos doctorcannot disagree:defineJobis scheduledsingletimeRelativesweep runs unscopedgroup/isolated)config.organization— the 2026-09-08 #16659 ruling, unchangedgroupis walled here for a measured reason rather than by analogy:resolveSystemWriteOrganizationrefuses an organization-less system insert under any wall andTenancyService.defaultOrgId()answersnull(ADR-0093 D3), so an organization-less group-wide sweep could read the whole group while every row it inserts is refused. Which organization such a sweep's inserts belong to is the part the maintainer said is not yet thought through; until it is answered,groupbehaves as walled.Ruled item 6 — a DISTINCT reason, never "binding failed"
getTriggerBindingAudit()gained a policy branch that outranks both binding branches, andactivateFlowTriggerdoes not calltrigger.start()at all when the switch is off. That second half is what keeps the reason precise: a refusal arriving as a THROW can only be reported through the catch that says Failed to bind, which is the one thing the ruling forbids this state from reading as. A binding failure is a defect with an engineering remedy; this is a deployment policy with an operator remedy, and the two send the reader to different places.The triggers keep their own copy of the gate and still throw — that is what makes the guarantee hold for a host that drives a trigger without this engine. In the engine's own composition it is belt-and-braces and never fires.
Retired pins, with their reasons (⛔ not deleted silently)
tenantIdis never conditional, inScheduleTrigger's handler and inTimeRelativeTrigger.sweep. The argument was sound while EVERY time-triggered run owed a declaration — conditional there meant sometimes we forgot. Under ruling G an absenttenantIdis a declared state reached through exactly one gate (single+ the switch on, where the deployment holds one organization by contract). ⛔ The key is omitted, not set toundefined: the ruling says the run carries no organization, and a present-but-undefined key is a different thing to every consumer that asksin. Pinned both ways.flow-schedule-organization-missing(Q4) is deleted, not re-severitied — id, exported constant, tests. The family's criterion is is this stack enough to know the flow is dead? and the honest answer is no: the switch and the posture decide it and neither is in any stack. Its absence is pinned positively (the exact fixtures the rule fired on now produce[], with a live control proving the function still reports the other rules). ⛔ The bind-time near-miss diagnostic (describeMissingScheduleOrganization) stays — it fires only where the key is required.Q3 — packaged
defineJobcron jobs are under the SAME switchGated in
packages/runtime/src/app-plugin.ts, in thekernel:readyloop that schedulesdefineStack({ jobs })entries — the exact "authored by a package" boundary. ⛔ Platform-internal jobs are not gated and do not pass through there: approvals escalation, the lifecycle Reaper, the messaging dispatch loop and membership backfill each schedule themselves from their own service plugin.Narrowing I did not expect, flagged for review
Two, both stated so the reviewer has the map:
organizationScopeIsInertForno longer warns when no organization is declared. That warning is a disclosure about a declaration; on a flow that declared nothing it would invent a containment claim the author never made.timeRelativedescriptor is invalid — that is the wrong remedy at the wrong door. Pinned.Acceptance notes
os doctorprints the effective value in BOTH states, wherenodeEnvCheckprints only the unset one. OFF is the state in which a shipped capability silently does not run, so a doctor that said nothing by default would leave the operator to discover the switch from the audit line, or not at all. Status isok(the file's only informational status; wideningHealthCheckResult.statusfor one row would re-render every other check) and thefixtext carries the next step in both directions.saascomposition loses its eight remainingscheduleflows by default; its tests that boot scheduled flows need the switch on. hotcrm#1892 and cloud#2216 wait on that relay.notifydelivers nothing on a multi-organization install: the run carries no organization, so the tenant-scoped inbox/delivery writes are refused (#8844) while the run reads healthy #16659) ⇒ reported, ⛔ not posted; the seat files follow-ups.pnpm dev --freshsets the switch) is the implementer's call: left alone deliberately. The local harness boots undersinglewith the switch unset, so the showcase's two flows and app-todo's two are listed as disabled by deployment policy — which is now the documented default experience, and makingdevspecial would hide from a developer exactly the state every fresh deployment is in.noted, not filed:packages/lint/src/validate-flow-trigger-readiness.tshas a long// 1f.measurement narrative about three runtime channels that each skip an unroutable flow; it is accurate but now sits beside a retired 1g and reads as one block. Cosmetic, no behaviour. Carrier: the next PR that touches this rule family.Tests
Every touched package's own suite, plus the three-state gate pinned at both layers. See the report comment on #17396 for the gate ledger and exit codes.
Generated by Claude Code