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 <[email protected]> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]> 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 <[email protected]> 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 <[email protected]> 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 <[email protected]> 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 <[email protected]> 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 <[email protected]>
|
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
|
| reading | value |
|---|---|
| merge base | d4554d4f5ae |
.changeset/schedule-trigger-acting-organization.md on the merge base |
present |
…on origin/main today |
present |
| added by | ecdfc94118 — 「fix(triggers,spec,service-automation)!: a time-triggered flow declares its acting organization and the run executes as it (#17334)」, a different PR |
| this branch's edit to it | a deliberate prose rewrite, not an overwrite-by-filename-collision |
⇒ DELIBERATE CORRECTION. ⛔ The COLLISION remedy (git checkout <merge-base> -- <file>) is the one thing not to do here: restoring it would republish sentences this PR makes false.
The note, and what changed under it
.changeset/schedule-trigger-acting-organization.md is #17334's pending, unreleased note for #16659. This PR narrows what it announces, so the branch rewrites it in three places:
- Title — 「a time-triggered flow declares its acting organization」 → 「…declares its acting organization behind a tenancy wall … (A schedule-triggered flow's
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, narrowed by [Decision] 平台自带的四个定时示例流一个都声明不了组织 —— 而新规则要求它们必须声明 #17396)」. - A new
⚠️ banner in front of every statement, because two deployment facts now precede them and neither is metadata: package-authored scheduled work is gated byOS_AUTOMATION_SCHEDULED_WORK_ENABLEDand is OFF by default in every tenancy posture and every kernel (while off, nothing below happens because nothing arms); and with it on, the declaration requirement applies under a walled posture (group/isolated) only — undersinglean armed time-triggered flow declares nothing and resolves the deployment's one organization exactly as before A schedule-triggered flow'snotifydelivers 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. - The
@objectstack/lintbullet — 「teachesvalidate-flow-trigger-readinessthe requirement … severitywarning」 → 「nothing, after [Decision] 平台自带的四个定时示例流一个都声明不了组织 —— 而新规则要求它们必须声明 #17396」, becauseflow-schedule-organization-missingis deleted in the same window and was never published. The stated reason is the rule family's own criterion — is this stack enough to know the flow is dead? — answered no: the deployment switch and the tenancy posture decide it and neither is in any stack.
The branch also amends the note's BREAKING paragraph to record that #17396 moves one sentence in the widening direction: under single with the switch on, a flow that #17334's changeset would have left unarmed now binds and runs.
⇒ Both changes land in the same launch window, so no released version ever saw the wider rule.
What is being asked
Confirm that correcting #17334's pending release note in place — rather than shipping it as written and correcting the CHANGELOG after release — is the right call. That is a release decision, which is why the gate stays red until a person says so rather than letting a diff shape route around it.
⛔ Nothing else about this PR waits on that answer: the other red (Lint & Repo Gates → check:type-source-resolution, @objectstack/service-automation via tsconfig.test.json and @objectstack/trigger-schedule, both on new dist-resolved @objectstack/types imports) is this PR's own and is being worked now.
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 <[email protected]>
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 <[email protected]>
…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 <[email protected]>
#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 <[email protected]>
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