Repository navigation
fix(triggers,spec,service-automation)!: a time-triggered flow declares its acting organization and the run executes as it - #17334
Conversation
… its acting organization and runs as it A `type: 'schedule'` flow and a `time_relative` sweep launch their runs from a job tick, which carries no identity, so `AutomationContext.tenantId` was never set. On an install holding more than one `sys_organization` every tenant-scoped write beneath the run was then refused by the #8844 guard — the inbox rows a `notify` node emits and the `sys_automation_run` history row — while the tick still summarised itself healthy. - `@objectstack/spec` declares the start-node `config.organization` key, its value schema, the two kinds that owe it, the near-miss spellings an author reaches for, and the one refusal sentence every enforcement point says. - The engine lifts the declaration onto the schedule / time_relative binding. - Both triggers REFUSE to bind a flow that declares none, naming it at `error`, and thread the declared organization onto the run as `tenantId`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
… differential controls Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…reference total Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…gfood pin The pin's subject is the trigger's own run-context construction, so a dist merely behind would run it green against the old one — the exact shape this card is about. check:test-source-alias and check:type-source-resolution both name this as the required spelling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ine records it `FlowTrigger.start()` is `void`, so logging the refusal and returning left the engine free to run `boundFlowTriggers.set(...)` and log "bound" one line later: `getFlowRuntimeStates()` answered `bound: true`, `getTriggerBindingAudit()` skipped the flow, and the CLI startup summary said every triggered flow was wired. Both time triggers now throw the same sentence they log, which is the engine's designed catch path — the flow is never marked bound and the audit lists it with `binding failed — see earlier warnings`. Also folds the trigger's inline near-miss scan back into `packages/spec` (`findScheduleOrganizationNearMissInConfig`, which takes the start-node config a trigger actually holds), drops the two consumer-less exports (`TIME_TRIGGERED_FLOW_KINDS`, `requiresScheduleOrganization`), makes the near-miss vocabulary module-local, renames the module to `schedule-organization.zod.ts` so its source ships and the docs generator gives it a page, and corrects the two comments that claimed `FlowSchema` emits the refusal sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
The fixture comment promised a refusal suite that did not exist. It exists now: the throw itself (the F1 contract), the `error` line carrying the same sentence the engine's audit points at, near-miss naming, the "hot re-publish removes the key" stop() limb, per-flow isolation, the no-`error`-channel fallback, and every limb of `resolveBindingOrganization`. The time-relative sweep gets the same two load-bearing pins, and `packages/spec` gets the unit test every sibling module in `src/automation` already had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…l B honest Consequence (3)'s dogfood pin asserted only that the job service was never asked. That says nothing about what the ENGINE recorded, which is where the refusal was being lost: `getFlowRuntimeStates()` answered `bound: true` and `getTriggerBindingAudit()` skipped the flow entirely. Both are now pinned, each with the declaring flow as its paired control. Control B's memory limb asserted `status < 300` under a message claiming it pinned a 503 refusal — opposite polarity, so it certified nothing. It now states plainly that the HTTP control is unavailable on this driver and pins the reason at the seam that makes it so: a tenant-scoped read is refused with MEMORY_MULTI_TENANT_UNSUPPORTED, so the exemption expires by itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ished surface The trigger-schedule README (shipped in files[]) still showed both worked examples without `organization` and said they auto-launch; after this change those exact flows are refused. Both now declare the key, and the README states the refusal and where it shows up. flows.mdx gains a dedicated "The acting organization" section and both worked schedule examples declare the key; hooks.mdx and capabilities.mdx point at it so "needs `triggers`" is no longer the whole story. The showcase digest's docstring stops promising that it fires: as a package-shipped flow it has no legal organization to name, no placeholder may be invented, and what such a flow should do instead is #17150's decision. Drops the unrelated esbuild@0.28.1/0.28.2 swap from pnpm-lock.yaml; the diff is now only the link: entry the new dev-dependency needs, and `pnpm install --frozen-lockfile` leaves it byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…t set `schedule-organization` now has its own reference page instead of landing in the "Misc (no single source file)" bucket, and api-surface / export-origins drop the three consumer-less exports. The changeset's export list matches what the barrel actually publishes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…w module Two gate findings the .zod.ts rename brought into scope, both real: check:spec-parsed-alias only reads *.zod.ts, so `ScheduleOrganization` was declared with z.infer where ADR-0122 reserves the bare name for the author state; and llms.txt counts *.zod.ts modules, so the automation domain and the total were one short. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
`ScheduleOrganizationSchema` is a bare `z.string().min(1)` — no coercion, no default, no transform — so input === infer and it takes a pin rather than a permanent `ScheduleOrganizationParsed` synonym. The note records that the module is not new, only its `.zod.ts` name is: this family of gates reads `*.zod.ts` only, so the violation sat green behind an extension. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…anization-r2 os-regen artifacts taken from main where both sides moved them (content/docs/references/index.mdx); the branch's own generated entries kept. The one content conflict, packages/spec/src/type-alias-convention.pin.test.ts, is a semantic merge of two stacking intents: main's -4 (ui#6206-B filter orthography converting four pins to XParsed pairs, 815 -> 811) and this branch's +1 (Iso871, ScheduleOrganizationSchema). Merged truth 812, recomputed from the file rather than arithmetic. Regeneration follows in its own commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Discharges the os-regen deferral recorded by the merge commit. `gen:schema` then `gen:docs` on the merged tree: totals 198/1583 -> 199/1584, automation 13/73 -> 14/74, and the `schedule-organization.zod.ts` row. Every other generated artifact re-derived byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
📓 Docs Drift CheckThis PR changes 5 package(s): 26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 136 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 e59beff040bec2069e82a7470b5028335ccd6941 && git checkout e59beff040bec2069e82a7470b5028335ccd6941
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 31064ca0ef30ed4c03482183f96baba11c8d0a76 18c0f6b44bb9ef31cacb0c3af02d65eb1093ab99 && git checkout -B drift-repro 31064ca0ef30ed4c03482183f96baba11c8d0a76 && git merge --no-ff 18c0f6b44bb9ef31cacb0c3af02d65eb1093ab99
node scripts/docs-audit/affected-docs.mjs --json 31064ca0ef30ed4c03482183f96baba11c8d0a76
|
…clared organization
The declaration reached the run and never the query. A sweep declared for org A
still matched rows in every tenant — `find` carried `context: { isSystem: true }`
and nothing else — and then launched a run stamped A about B's record: the run's
`update_record` matched nothing (silently, being scoped to A), `notify` posted
into A's inbox about B's record, and the history row was stamped from the
subject, landing under B. That is the cross-organization scheduled task the
ruling forbids, with a declaration papering over it.
The declared organization now rides the sweep's own query as
`context.tenantId`, the same ExecutionContext axis the run already uses:
`Engine.buildDriverOptions` turns it into `DriverOptions.tenantId` and the
driver scopes the read. ⛔ Not a hand-built `organization_id` predicate — that
would be a second implementation of tenancy inside a trigger, hardcoding a
renameable column, selecting nothing on a platform-global object, breaking a
federated one, and reading as scoped to a driver that never learned scoping was
wanted.
Elevation and tenancy stay independent: `isSystem` says what the sweep may see,
`tenantId` says whose rows they are.
Silence is closed on both new edges: a store that cannot honour the scope
refuses the call and the sweep now logs that at `error` (stderr survives the
CLI's boot-quiet window), and an object the engine exempts from scoping
(`tenancy.enabled: false`, `external`) gets a bind-time warning saying the
declaration cannot narrow this sweep. The bind line names the acting
organization so the sweep's reach is readable from the boot log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
The existing pin read `expect(calls[0].context).toEqual({ isSystem: true })` —
it was pinning the defect, so it is replaced rather than relaxed: exact equality
against `{ isSystem: true, tenantId: <declared> }`, which reddens the moment the
sweep asks for no scope.
Four new pins:
- every window query carries the scope, not just the first (offset mode issues
one per offset), and `where` still holds only the date window, so no
organization predicate was hand-built onto it;
- DIFFERENTIAL — matching rows in TWO organizations, only the declared one's
record launches a run. The double implements the driver's documented scope
(`org = :tenant OR org IS NULL`) and, with no scope, no predicate at all, so
its "absent" arm reproduces the defect instead of hiding it;
- a store that cannot honour the scope (driver-memory's #16589 refusal) is
reported at `error` naming the flow, never answered unscoped;
- a `tenancy.enabled: false` object is disclosed at bind — the declaration
cannot narrow that sweep — with a paired control that an ordinary object
draws no such line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
The differential control the branch never had: `sched_org_sweep`, a `time_relative` flow declared for org A, with matching rows in BOTH A (one) and B (two). The discriminating number is the count of LAUNCHED RUNS — 1 with the fix, 3 on the defect — because a pin asserting only "A's row was touched" passes on the defect too. Three properties, three producers: - selection: exactly one run, and it names A's record; - the data plane: `update_record` lands on A's row and on no other (the half the branch left unpinned — the earlier fixture flow was start -> notify -> end, so nothing would have gone red if the run's narrowing were wrong); - disclosure: no `sys_notification` names a B record. An unscoped sweep emits that notification BEFORE its write silently matches nothing, so a fix that narrowed only the writes would leave the leak open. The memory limb answers the other question the card is about: a store with no tenant isolation REFUSES the scoped sweep (#16589) and the failure is reported at `error` naming the flow, with a paired control proving the refusal is about the scope and not about the fixture (the same query unscoped still sees all three rows). The fixture object gains `due_date` as `datetime`, not `date`: the window is a pair of ISO instants, and a column the driver truncates to YYYY-MM-DD would put a per-driver truncation rule between the fixture and the property under test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
`validate-flow-trigger-readiness.ts` contained zero occurrences of `organization`, against the invariant `engine.ts`'s own trigger-kind resolver states: it is shared with `defineStack`'s trigger-capability refusal and with this rule file, "so the runtime cannot drift from what authoring accepted". Both time triggers refuse to bind a flow declaring no `config.organization`, so `defineStack`, `os lint` and `verify_build` were all passing a flow the trigger then refused — an author's first signal was a production stderr line at boot. ⛔ No judgement is re-implemented: `resolveFlowTriggerKind` says which flows owe the key, `resolveScheduleOrganization` says whether one was declared (so a present-but-unusable value is judged identically here and at bind), and `describeMissingScheduleOrganization` writes the sentence. Severity is `warning`, and that is MEASURED rather than argued. On the family's own criterion (#5762 — is this stack enough to know the flow is dead?) it belongs at `error`. Flipping it to `error` and building the shipped example app was run: `objectstack build` on examples/app-showcase FAILS, naming `showcase_task_due_reminder` and `showcase_scheduled_digest` — and neither can be repaired by authoring, because the only legal value is a `sys_organization.id` minted per install at runtime and a placeholder id is strictly worse than the omission. Promoting the id is therefore a consequence of the open maintainer decision about package-shipped time-triggered flows, not a lint choice; the probe was restored byte-identically and the two example builds pass with the rule at `warning`, flagging exactly the four shipped flows. Fixture triage in the rule's own suite: every time-triggered fixture that is ABOUT another rule now declares an organization, so it keeps isolating its own subject; the fixtures that are about the missing key omit it deliberately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
ACCEPT — at-tier PASS on the head that lands, gate cleared on both carriers, undrafted and enqueued
The four landing pre-checks, on head
|
| # | pre-check | reading |
|---|---|---|
| ① | at-tier verdict for the head that lands | PASS, zero must-fix, three advisories — 5620983965 |
| ② | check-clause2-carriers --pair 17334 |
exit 0, both carriers agree — re-run after the clear, still 0 |
| ③ | check-governed-merges --test over the 44 final paths |
0 governed · lit control AGENTS.md exit 3 ⇒ the instrument fires |
| ④ | CI newest-run-per-check-NAME plus the combined commit status | 41 runs, all completed: 0 failure, 0 pending · combined status success |
⇒ ③ settles the merge route: ⛔ not governed, so this is an ordinary queue landing and ⛔ not a hand merge.
The verdict was RE-TAKEN, not inherited
The earlier review (5619271717) returned PASS WITH FINDINGS on 5fb332bdc. That head then moved a long way — a full origin/main merge, M1, M2, the A1 fold, and the Iso872 renumber — so its verdict did not transfer. ⭐ A verdict on a head that no longer exists is not a verdict on this one. The re-review measured the delta in its own worktree at 18c0f6b44b.
What it independently re-derived rather than accepting: the risk list from .gitattributes at the merge commit (18 patterns); 8 branch / 46 main / exactly 1 contested path; the 45 main-only routed paths byte-identical to main-at-merge (0 diffs, lit 7, dark 0); the contested file carrying both sides and totalling 192; llms.txt summing to 199; the pin test at 783 with 0 duplicate Iso ids and tsc --noEmit exit 0 — ⭐ that last one matters because the duplicate-identifier failure mode is a type error vitest cannot see, so the suite passing would not have caught it; the api-surface delta at exactly +5 rows with the folded symbol reading 0 everywhere; and protocol 18 confirmed from registry.ts:5062's own header plus the sibling 18.cloud-subpath-retired.
⭐ It also refused to report a red it could not attribute: the spec suite showed 9 failures in two files this PR does not touch, traced to its own /tmp-rooted worktree resolving npx tsx against an absent /tmp/node_modules — reported as NOT MEASURED, not as a failure of this PR.
One advisory worth reading, since it corrects this PR's own narrative
The body says the merge=os-regen driver dropped main's side on content/docs/references/index.mdx and that step 2 restored it. The reviewer measured the committed merge and found the opposite pre-commit state — the commit carried main's side and had lost the branch row, which regeneration then restored. ⇒ ⭐ The outcome is correct either way (the file at HEAD carries both sides, verified), and the operational lesson is unchanged and still the important one: the driver exited 0 while a side was missing, with no conflict marker. Only the direction in the narrative is backwards. Recorded here rather than by editing the body — a REST PATCH /pulls appends a second attribution footer.
The other two advisories: #17446 is not yet cited at the place in the changeset where the driver-memory gap is described, and the declaration-map delta is +2 (types only) rather than +5, which is by design.
M2 — the gap is filed, and the dedup changed where it goes
The functional gap M2 uncovered — on driver-memory a time-triggered flow touching per-organization data has no legal configuration — is filed as #17446. ⭐ ⛔ It did not go into the decision box: a dedup search found #5499, the maintainer's standing 2026-08-05 ruling freezing the driver-memory / driver-mongodb family, whose direction is replacement by sqlite :memory: and whose standing triage rule sends new cards of that family to pm:on-hold referencing it, ⛔ not to pm:queue. The round's recommended option A is that ruling, for the whole component. I also checked #5499's exception-escalation channel (a defect affecting CI's green correctness overrides the freeze) — ⛔ it does not apply: this PR's own dogfood test runs the memory leg and asserts the refusal, so the semantics are pinned, no false green.
Both gate carriers cleared in ONE stroke, deliberately
needs:contract-review removed from card #16659 and from this PR, seconds apart, each read back. ⭐ Two removals close together is what a legitimate clear looks like in the event stream; a lone removal is indistinguishable from a strip, and 「闸门标签被剥不是红灯是放行」. I broke exactly this on #16929 earlier today by writing a whole label set, so both writes here were four-step: read the current set → remove only the target → write the union → read back the diff. Card #16659 read back as bug, priority:p1, pm:dispatched, domain:services — nothing else moved, and its domain:services lane label is untouched: ⛔ this seat took the card by handover, it does not re-lane it.
Undrafted and auto-merge armed. The merge queue decides the method; ⛔ this seat does not merge its own PR and does not bypass the queue.
Generated by Claude Code
…Type` member at the registration door (objectstack-ai#16319) (objectstack-ai#17444) * wip(objectstack-ai#16319): refuse a field with no/unknown type at the registration door Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * wip(objectstack-ai#16319): narrow the driver refusal to an absent type; triage objectql fixtures Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * test(objectstack-ai#16319): pin the registration-door refusal, the boot seam and the two downstream defences Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * chore(objectstack-ai#16319): changeset with the BREAKING note for the refused field-type class Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * fix(objectstack-ai#16319): type the new tests' engine query options so the erasure ratchet holds Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * chore(objectstack-ai#16319): delete the four test-typecheck-debt entries the fixture triage graduated Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * fix(objectstack-ai#16319): spell the cross-field fixture's text columns `text`, the member the door admits `CROSS_FIELD_OBJECT_FIELDS` declared `stage` and `owner` as `type: 'string'`. `string` is absent from `FieldType`, so this PR's own registration door refused the whole `cross_field_deal` object and the runtime suite's `beforeAll` threw before its subject ran — CI shard `Test Core (3/6)`, 1 failed | 250 passed. `text` is not a re-typing. `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `text` is the spelling of the column `string` was already producing. Matches `id` / `organization_id` in the same map and the text columns of `CROSS_FIELD_OFFSET_OBJECT_FIELDS` below it. The emitted column does move `varchar(255)` -> `TEXT` (measured on sqlite-wasm), which is inert here: no index keys either column, `initObjects` is passed no indexes, and the longest value in the corpus is four characters. All four `initObjects` consumers stay green. A static census over the tree — `fields:` regions resolved through cross-package imports, classified as ObjectStack field maps, `type` checked against the `FieldType` union read out of `field.zod.ts` — reports these two as the only non-member declarations reaching a `registerObject` door. After this change that count is zero; the 108 that remain all reach `driver.initObjects` only, which the changeset's scope section deliberately leaves alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * fix(objectstack-ai#16319): spell the schedule dogfood fixture's `touched` flag `boolean` `packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts` declared `touched: { type: 'checkbox' }`. `checkbox` is not a `FieldType` member — the enum spells the multi-value checkbox GROUP `checkboxes` and the scalar flag `boolean` — so this PR's registration door refuses the whole `sched_org_target` declaration and both objectstack-ai#16659 pins fail at `bootStack` on `[sqlite-wasm]` and `[memory]`. Neither side is wrong alone. The declaration landed in ecdfc94 (objectstack-ai#17334), after this branch's merge base `d57611dfd3`, where nothing refused it; the door is this card's deliverable. The merge queue tests the PR merged into current `main`, which is the first place the two meet — so this is the merge, not a defect in either diff. `boolean` and not `checkboxes`: the fixture's `update_record` node writes `touched: true`, and both pins read it back as a scalar (`r.touched === true || r.touched === 1`, `Boolean(r.touched)`). `checkboxes` stores a JSON array of option values, whose EMPTY set is truthy — the sweep pin's differential would then report rows nothing touched as touched. The member is chosen from the enum's own grouping and from what the tests assert, not from the refusal message's Levenshtein suggestion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --------- Co-authored-by: Claude <noreply@anthropic.com>
…mit; merged-write rule; account-suspension recovery readings (objectstack-ai#18242) Fixes objectstack-ai#17374 Second half of the shared-identity rate-limit card. The first half (PR objectstack-ai#17860, landed as `ed8dea17b`) placed expectations 1 and 4 — the identity-bound rate-limit rows in `references/rest-channel.md` and the destroyed-evidence NOT MEASURED line in `.claude/agents/os-dev.md`; expectation 5 is met by the half-state patrol's H40 row. This PR lands what was deferred behind same-file serial: expectation 2 (merged writes), expectation 3 (the account-suspension recovery steps as readings), the reconciliation of the two quota rows that still prescribed the same-identity channel switch, and the reviewing seat's NOT MEASURED line. With this the card's five expectations are all placed. F5 (GitHub App / machine users) is the maintainer's infrastructure decision and not this card's; the disciplines here hold independently of it. Clause-②: no — no published `skills/**` file moves; no operator or contract semantics. skip-changeset — nothing published moves (`.claude/**` and `scripts/pm/**` sit outside every package's `files[]`). ## What changed — 4 files, +60 / −7, head `694b1d2` on base `b3b43b6` ### `references/platform-readings.md` — 454 → 459 (+5 under the standing one-file exception) Two quota-block rows re-conditioned in place (line-neutral; they pay nothing and buy nothing): - 「限流、403、传输失败都要试过另一侧才说得出我没手段。」 → 「403 与传输失败要试过另一侧才说得出我没手段;限流先比身份,同 ID 的他侧不是手段。」 (114 B) - 「⇒ MCP 限流先探 REST 再定退避,⛔ 不据一侧限流把整个平台的写都停掉。」 → 「⇒ MCP 限流先 `GET /user` 比 ID:同 ID 的 REST 满额不是退路,写排队到重置;异 ID 才是。」 (109 B) Until this PR both rows told a seat to do the exact same-identity channel switch that `rest-channel.md`'s identity-bound rows (landed by the first half) forbid. The 403 and transport halves stay as they were: those are per-side readings, and the row above them (「任一侧的拒绝只是那一侧的读数」) is untouched. Reads are untouched too — the row below (「MCP 的读限流与写限流彼此独立」) still governs them, and the identity test is itself a read on the other side. 「写排队到重置」 points at the standing queue-into-the-patrol-word row (:159) rather than restating it. Five new rows after the retry-alignment row (the end of the quota-exhaustion prescriptions), one measured event per row, each under 120 B: 1. 「停用报文 account was suspended 遍及 /rate_limit 与 git,不给理由;非会话门 403、非限流。」 (108 B) — the recognition reading: the third 403 shape beside the session gate and the rate limit, which is what tells a seat the four steps below now apply. 2. 「账号停用销毁其名下 PR、卡与评论;分支与 commit 属仓库照留远端 ⇒ 代码从未真丢。」 (112 B) — F3 step 1. 3. 「被销毁的 PR 仍占分支名:API 答 404,同名开新 PR 仍被拒 ⇒ 同批 commit 推新分支名再开。」 (114 B) — F3 step 2. 4. 「本地对象库是最后备份:复核时 fetch 过的每条分支,其 head 在停用后仍在本地可推。」 (110 B) — F3 step 3. 5. 「重建 PR 正文自报四件:head 逐字节同、无 rebase/amend/squash、数字出自旧基底、CI 为准。」 (113 B) — F3 step 4. Dedup table for the exception (候选/落地/已有/拒收): candidates 5 / landed 5 / already present 0 / refused 0. The family grep `suspended`, `停用`, `销毁`, `幽灵`, `重推`, `重建`, `对象库`, `分支名` on `origin/main` at `b3b43b6` hits only the Routine-rebuild rows (:445–:446, :453), the issue-transfer rebuild row (:144) and the landing criterion (:366); none carries any of the five, and the later suspension-row family the dispatch word anticipated does not exist on `origin/main`. Ratchet: ceiling 454 → 459 in `scripts/pm/check-skill-line-ratchet.mjs`, with a FIFTEENTH `ruledRaises` record citing the standing exception verbatim — 「唯一例外:`platform-readings.md` 增量抬上限到落地行数,免决策卡,记 `ruledRaises` 引常设裁决。条件:席位验收评论逐条核实、去重计数(候选/落地/已有/拒收)、一事一行、不计重排」 — and the increment accounted for line by line beside the ceiling. The cross-file-move arithmetic re-derives unchanged (`+11 against a net source decrease of 20`). ### `SKILL.md` 〈平台读数纪律〉 — 812 → 812 (equal-line, paid by density) Added as the section's last line, beside the pre-dispatch `rate_limit` read: - 「写少而大:同卡同轮结论合成一条评论,⛔ 不放慢单笔、不攒着一次性发;写量按身份计。」 (117 B) Paid by folding the two maintainer-abort lines into one: 「维护者中止只在有显式信号时成立:原话,或宿主回报 stopped by the user。」 + 「⛔ 不据推断立一道没有重启条件的门;判据是信号不是症状。」 → 「维护者中止只认原话或宿主回报 stopped by the user,⛔ 不据推断立无重启条件的门。」 (108 B). Every operative clause survives (explicit signal only; no inference-built gate without a restart condition); 「判据是信号不是症状」 is the first clause said twice. `references/core-rules.md` :46 already digests this pair in one line, so the mirror is unchanged (151 / 151). A2 measured on `b3b43b6`: `grep -c '合并\|少而大\|节流' SKILL.md` counts 0 lines in the write-merging sense — every 合并 hit is merge-queue / merged-PR vocabulary — against control `限流` 3 lines. The quota rows in `platform-readings.md` (:103–:105: the per-minute secondary limit, the ~1 s spacing between mutations, the same-second burst that hits the minute wall) answer the BURST half of the card's shape and not the MERGE half: nothing on the seat surface said fewer-and-larger, and nothing said not-slower. The dev side already carries a merged budget (`os-dev.md` 写预算四笔); the seat side now carries this line. ### `references/review-checklist.md` 〈测试与门禁证据〉 — 77 → 77 (equal-line, paid by density) - 「复核项证据已销毁(评论、卡或 PR 答 404)⇒ 记 NOT MEASURED 并写因,⛔ 不记通过或无旗。」 (113 B) — directly under the INCONCLUSIVE row, so the reviewer's verdict vocabulary now has all three: INCONCLUSIVE (the positive control failed), NOT MEASURED (the evidence is destroyed), 不入账 (a dead-tree reading). A5 measured: `grep -c 'NOT MEASURED\|销毁\|404'` on `review-checklist.md` and on `contract-review.md` at `b3b43b6` = 0 and 0 (control `INCONCLUSIVE` = 2 in review-checklist), so the reviewer had no line to read; the first half's line lives in the dev's file. Paid by folding the dead-code deletion pair (:75–:76) into 「以死代码或不可达为由的删除,PM 先在 `origin/main` 用带引号精确名核引用面,再 ACCEPT。」 (113 B): the rule survives whole and the deleted half (「这是断言不是 diff 里的事实,而这一查只花十秒」) is rationale. Face note: the claim comment's file surface names `platform-readings.md`, `SKILL.md` and `core-rules.md`; the dispatch word's A5 asks for exactly this one reviewer line in `review-checklist.md` or `contract-review.md`, inside the ratchets, and this is the one deviation from the claim's list. All 11 open PRs' file lists were read at 2026-09-15T02:46Z, immediately before this PR was opened; none touches any file in this diff. ## Acceptance against the card 1. **Positive** (client A refused, client B has quota): the quota section itself now answers 停 for the same user ID — 「限流先比身份,同 ID 的他侧不是手段」 and 「同 ID 的 REST 满额不是退路,写排队到重置」 — and 「异 ID 才是」 only when `GET /user` answers a different id. Measured on this container: `GET /user` on the REST credential answers `os-zhuang` id 277994282, the same login as the card's assignee, so PM and dev are one identity in this seat — exactly the case the rule is for. 2. **Negative control** (normal-quota writes unaffected): every changed rule fires only on a refusal, a suspension or a destroyed record; the new SKILL.md line says 「⛔ 不放慢单笔、不攒着一次性发」 in so many words, so it can neither slow a write nor push a seat toward the burst shape. No transport is banned, no 「下次注意」 is written, no green-widening exemption is introduced — the card's three 「不要走的路」 hold. 3. **Rehearsed recovery**: on record rather than staged. The incident's own recovery is the rehearsal — PRs objectstack-ai#17332, objectstack-ai#17334, objectstack-ai#17336 and objectstack-ai#17339 were rebuilt on new branch names from the same commits with byte-identical heads, each self-reporting the four items row 5 now spells. A staged suspension is not something this PR can or should perform; the five rows are the steps that recovery took, written so the next seat does not rediscover them. 4. **Ablation** (grep line counts, `origin/main` at `b3b43b6` versus head `694b1d2`, with a lit control in the same file): - `platform-readings.md`, pattern 停用 / 销毁 / 新分支名 / 对象库: 0 → 4 (row 5's term 自报 is excluded because :220 already carries it in another sense; `自报四件` alone reads 0 → 1); control `限流` 10 → 11. - `platform-readings.md`, pattern 比 ID / 比身份: 0 → 2; the deleted instruction 先探 REST / 整个平台的写: 1 → 0. - `SKILL.md`, pattern 少而大 / 攒着 / 按身份计: 0 → 1; control `限流` 3 → 3. - `review-checklist.md`, pattern NOT MEASURED / 销毁: 0 → 1; control `INCONCLUSIVE` 2 → 2. Remove the new text and the positive case is again answered only by `rest-channel.md`, with the quota section arguing the other way. ## Mechanism assumptions from the dispatch word, measured - A1 — confirmed: :123 and :126 on `b3b43b6` prescribed the switch; both re-conditioned in place as above. - A2 — measured as above; a rule line was owed, and it lands in 〈平台读数纪律〉. - A3 — the family grep found no suspension rows on `origin/main`; all four F3 steps plus the recognition reading were absent and land. - A4 — recorded under acceptance 3. - A5 — measured as above; the reviewer line lands in `review-checklist.md`. ## Gates — head `694b1d2` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; change set taken from git off merge base `b3b43b6ea`, 4 committed paths) derived 40 commands. 39 of the 40 ran in the foreground with the exit captured by redirect before any pipe, and every one exits 0; the 40th is the tool's own self-test, run detached (below). `--ran` on the exit-carrying record reconciles 40 derived / 40 run / 0 unrun. - `check:pm-skill-ratchet` (self-test + gate): `cross-file move into platform-readings.md: +11 (314→459, less 134 lines of ordinary ruled raise) against a net source decrease of 20` · `declared cross-file moves: 1, total ceilings down 9 lines`. - `check:pm-dispatch-gates` (the tool's own self-test, 1723 cases): detached per its header's foreground-cap warning, waited on in the foreground with `tail --pid`; verdict line `✓ dispatch-gates self-test: 1723 cases pass.` - The three lint-package families ran after `pnpm --filter "@objectstack/lint..." build` under `os-verify-lock.sh` (VERDICT command-exit 0, held 220 s, waited 0 s). - `check:pm-skill-id-lint`, `check:skill-frame-sync`, `check:nul-bytes`, `check:ratchet-remedy-authority`, `check:pm-governed-prose`, `check:required-contexts`: exit 0. - Three roster families the derivation marked as sitting under `scripts/` beside this diff were run in addition: `check-published-list-mirrors`, `check:pm-label-desc-cap`, `check-skills-token-ratchet` — all three exit 0. - Not measured locally, CI's: the 3 workflow-value families, the Test Core job, the 11 wide-population families, the 14 changeset-pending families (skip-changeset), the remaining artifact rosters; repo-wide `pnpm lint` not run. - Control-byte scan on the four files: 0 hits. No changed line over 120 B in the three markdown files. No card number in any added operative line. ## Acceptance notes - noted, not filed (承接者: the skills seat, holder of the same-batch tension ruling): `platform-readings.md` :97 「⛔ 不据限流报文里的 user ID 推池子跨席共用」 and :143 「报文里的 user ID 只是报文」 stay as written — the ratchet's own record says both hold pending a discriminating read, and the first half's `GET /user` comparison is that read (the id in the refusal text names the identity and says nothing about cross-seat pools), so :143's 「只是报文」 could become 「只标身份」 at equal bytes in a later density pass. Not edited here because the ruling says neither is. - noted, not filed (承接者: none): `core-rules.md` 〈平台读数纪律〉 carries no digest of the new write-merging line; the mirror's own header says it adds no rules, and the claim conditions a mirror edit on a core clause moving, which none did. ## 维护者速读(草稿) **改了什么**:三份 PM 席位的规则/事实文件加一份门禁台账。① 事实表 `platform-readings.md` 配额段:把两条还在教席位「限流了就换另一条通道继续写」的行改成「先比身份,同一身份就停写排队」,并新增五行记下账号被停用时的识别信号与恢复步骤(分支和提交不丢、被销毁的 PR 仍占着分支名要换名重推、本地对象库是备份、重建的 PR 要自报)。② 席位规则 `SKILL.md`:加一行「写少而大」—— 同一张卡同一轮的结论合成一条评论,既不放慢单笔也不攒着突发,写入量按身份合计;用合并两行既有规则付账。③ 复核清单:复核项的证据被销毁时记 NOT MEASURED,不记通过;同样以合并两行付账。④ 棘轮台账:`platform-readings.md` 上限 454 → 459,按常设例外记录。 **为什么改**:这是 2026-09-10 整个 fleet 被停用的事故复盘卡的后半。前半已把「限流绑定身份」写进通道表;但事实表里还留着相反的指引,席位照读就会重演事故里的那个动作。恢复流程当时是现场摸索出来的,没写下来下次还要摸一遍。写入合并的口径此前完全空白。 **风险与代价(含回滚)**:纯文本规则与事实,不碰任何发布包,不改 CI 行为。代价是 `platform-readings.md` 多 5 行(每行 ≤120 字节,一事一行),其余三份文件行数不变。回滚 = revert 本 PR 的一个 commit,无迁移。误读风险:「写排队到重置」不是「所有写变慢」,新行已明写不放慢单笔。 **席位意见**: **你要做的**:审批本 PR(受管面,需要你的 approve 后由席位入队);若认为「写量按身份计」或恢复五行的措辞有误,直接改文字或留言,席位按你的话修。 --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ Co-authored-by: Claude <noreply@anthropic.com>
…ommits that decided them (objectstack-ai#20775) Part of objectstack-ai#20596 Clause-②: no ## What changed This is the twelfth stage of the `domain:services` lane of the dead-citation sweep. It covers `packages/triggers/trigger-schedule/src/**` and nothing else. By the seat's claim (`5903462246`), it is the largest package in the lane that no in-flight work holds, now that objectstack-ai#20599's PR objectstack-ai#20746 (which edited `time-relative-trigger.ts`) has landed. Later stages cover the other packages, so this PR says `Part of` and the card stays open. Every comment or docblock site in scope that cited a tracker number answering 404 has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), by the method of stages 1 to 11 (PR objectstack-ai#20609 as `422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR objectstack-ai#20658 as `9a4b2bb38`, PR objectstack-ai#20693 as `0e9ad74fb`, PR objectstack-ai#20708 as `9b384f63a`, PR objectstack-ai#20717 as `cbaf04c1f`, PR objectstack-ai#20729 as `d2820876f`, PR objectstack-ai#20737 as `4dfff176b`, PR objectstack-ai#20742 as `697845d19`, PR objectstack-ai#20757 as `cba417a8f`). That is **32 sites on 32 lines in 6 files, covering 2 numbers**: - 18 census sites (every census site this package has); - 14 sites in test comments, which the census defers; - no site the gate's grammar cannot see (the package has none, see Acceptance notes). Each rewritten line now cites the commit in `origin/main` history that decided what the line describes, and says in its own words what was decided: **2 distinct shas**. Neither number has an ADR or ruling record of its own (a grep of `docs/adr/`, `scripts/adr-anchors/` and the rest of `docs/` for both numbers finds nothing, and no ADR records the acting-organization decision or the driver-memory per-call refusal), so both anchors are commits, per ruling C's order. No number was dropped. Only comments changed. Every touched source file keeps its line count (32 lines out, 32 in, over 6 files), so no line citation into these files moves. Every one of the 32 changed lines carried a dead citation; there is no reflow line. No code token moves (see the guard below). **No citation number is added.** The only tracker number on an added line is the live `objectstack-ai#8844`, on the line it already stood on. Added minus removed is negative for the two dead numbers and zero for every other number, and no number is new to the diff. No PR number is the citation on an added line. 4 dead sites are left on purpose: three `describe` titles, and one comment that quotes one of those titles verbatim (see the list below). One more file: a `patch` changeset for `@objectstack/trigger-schedule`, because the rewritten prose ships (see Changeset below). ## Census: `trigger-schedule`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count below is its `allocated-but-absent` findings under `packages/triggers/trigger-schedule/`. Each run counts as a reading only because its board frontier equals the newest issue or pull-request number, read by a separate request just before and just after the run. | reading | tree | board | whole-repo `allocated-but-absent` | trigger-schedule sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `cba417a8f`, run 2026-09-30T03:30:14Z to 03:33:24Z | enumerated, 186 pages, frontier objectstack-ai#20769 (newest objectstack-ai#20769 before and after) | 823 | **18** | 18 | 2 | 2 | | after | head `226be8050`, run 03:37:59Z to 03:41:09Z | enumerated, 186 pages, frontier objectstack-ai#20769 (newest objectstack-ai#20769 before and after) | 805 | **0** | 0 | 0 | 0 | The before count matches the seat's census and A1 (18 sites: `objectstack-ai#16659` ×17 and `objectstack-ai#16589` ×1, in `schedule-trigger.ts` ×5 and `time-relative-trigger.ts` ×13). A1 noted that PR objectstack-ai#20746 edited `time-relative-trigger.ts` today; the before count above is taken on the base that already holds that edit. The whole-repo drop is 18, exactly this diff's census sites. The `resolves` tally is 33,038 in both runs, and `resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995) did not move either. The after run was taken on `226be8050`; the head `14314f49c` adds only the changeset. No run was truncated or discarded: both enumerations read 186 pages at the newest frontier. **Supplementary instrument, the whole scope.** The census does not read test files or strings, and this stage's scope includes test comments. So a second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) and `namesThisRepository` over every `.ts` file under `trigger-schedule/src` (14 files). It takes its verdicts from the before census's own board reading rather than from a second enumeration: a number is dead when that census reported it `allocated-but-absent`, and alive when that census judged it on this board anywhere (its `--list` extraction, 36,840 rows) and did not report it. Every number this package cites is covered by one or the other, so no number needed a separate read to be judged; the two dead numbers were also read one by one on the issues endpoint, and each answers 404. | reading | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---| | before, `cba417a8f` | 159 | **36** | 18 | 15 | 0 | 3 | | after, `226be8050` | 127 | **4** | 0 | 1 | 0 | 3 | Its src-comment column equals the census's 18, which is the control on the second instrument. The 123 live citations are the same in both readings, and the drop of 32 citations is exactly the rewritten sites. A third, raw reading (every `#` followed by 2 to 6 digits, whatever surrounds it) finds 162 occurrences and 36 dead before, 130 and 4 after. Beyond the gate's grammar it sees 3 tokens, none a tracker reference: the maintainer decision-batch ordinals `batch objectstack-ai#13`, `objectstack-ai#116` and `objectstack-ai#118`, which the gate's `NON_CITATION_HEADS` excuses by design. ## Per-number table Sites and files count every dead occurrence in scope at the base (comments and strings, tests included). `rewritten / left` counts the sites rewritten and the sites left. Each anchor was read in its message and diff, not only its subject, and `git blame` at the base puts every rewritten line in its anchor commit or in a later commit that descends from it (21 lines blame to the anchor itself; for the other 11, `merge-base --is-ancestor` of anchor and blamed commit exits 0). | number | sites / files | rewritten / left | anchor: what it decided | |---|---|---|---| | `objectstack-ai#16659` | 34/6 | 30/4 | `ecdfc9411` (PR objectstack-ai#17334): a time-triggered flow (`schedule` or `time_relative`) declares its acting organization on its start node as `config.organization`; the engine lifts it onto the binding; both time triggers refuse to bind a flow that declares none, naming it at `error` and THROWING so the engine records the refusal instead of reporting the flow bound; the run carries the declared organization as `tenantId`; and the time-relative sweep's own query carries it too, so the sweep SELECTS inside that organization (the review finding F2 its diff names), with a store that cannot honour the scope reported at `error` and an object the engine exempts from scoping disclosed at bind. Its body names `objectstack-ai#16659` twice (the three consequences pinned on both drivers, and the proof registered), and its diff names it on 65 added lines. The anchor the spec stage (`0f6dcac5e`) and the lint stage (`f29c83db1`) already give the same number | | `objectstack-ai#16589` | 2/2 | 2/0 | `555a89cbd` (PR objectstack-ai#17005): `driver-memory` gains a third seam, `assertCallNotTenantScoped`, called first in every driver door that accepts `DriverOptions`, which REFUSES a call the engine tenant-scoped instead of discarding the scope and answering every organization's rows; row-level isolation is deliberately not implemented. Its message does not carry the number, but its own diff writes the mechanism the two lines describe and names `objectstack-ai#16589` 30 times (the `[objectstack-ai#16589] Seam 3` markers and the guard's docblock), so it is the commit that decided it. New to the sweep | Every cited sha matches exactly one commit (`git rev-parse --disambiguate`, count 1 for each of the 2), and both are ancestors of the base (`merge-base --is-ancestor`, exit 0 for both; control leg: stage 1's landing `422db788a` exit 0; reverse leg, base against `ecdfc9411`, exit 1; the history is complete, `--is-shallow-repository` false, 15,160 commits; each anchor lies deeper than the control, 1,616 and 1,814 commits behind the base). Each of the 2 numbers answers 404 on the issues endpoint, which serves pull requests too. Independently, the package's own shipped `CHANGELOG.md` pairs `ecdfc94` with `objectstack-ai#16659` (line 149) and `assertCallNotTenantScoped` with `objectstack-ai#16589` (line 238). ## Wordings to check - **Tag swaps in brackets or parentheses.** 「[objectstack-ai#16659]」 became 「[commit ecdfc94]」 on 18 lines, 「(objectstack-ai#16659)」 became 「(commit ecdfc94)」 at `schedule-trigger.ts:251`, `:372` and `time-relative-trigger.ts:50`, and 「(objectstack-ai#16589)」 became 「(commit 555a89c)」 at `time-relative-trigger.ts:615` and `time-relative-trigger.test.ts:988`. - **Section rules.** `schedule-trigger.test.ts:327`, `time-relative-trigger.test.ts:794` and `:862`: the 16-character phrase replaces the 6-character number and the trailing rule loses 10 characters, so each line keeps its width exactly. The `:862` heading keeps 「F2」 beside the sha; F2 is the selection finding `ecdfc9411`'s own diff names. - **「before objectstack-ai#16659」** at `time-relative-trigger.ts:365` and `:543` became 「before commit ecdfc94」: before that commit the sweep queried with `isSystem` alone, which is the unscoped selection both sentences describe. - **「the objectstack-ai#16659 defect」** at `time-relative-trigger.ts:561` and `time-relative-trigger.test.ts:1371` became 「the defect commit ecdfc94 fixed」: a commit fixes a defect, it is not one, and the widening both sentences name is the selection half that commit closed. - **`schedule-trigger.test.ts:512`.** 「the exact defect objectstack-ai#16659's own refusal was shaped to avoid」 became 「the exact defect commit ecdfc94's own refusal was shaped to avoid」: the defect is a refusal that logs and arms anyway, and that commit is where the refusal became a throw so the engine records it. - **`schedule-trigger.test.ts:588`.** 「(the objectstack-ai#16659 suite above)」 became 「(commit ecdfc94's refusal suite above)」, so the pointer still lands on the refusal suite at `:337`. ## The 4 sites left - **Test strings, 3 sites on 3 lines**, all `describe` titles, left as stages 1 to 11 left theirs: `schedule-trigger.test.ts:337` and `:462`, `time-relative-trigger.test.ts:805` (all `objectstack-ai#16659`). - **One comment that quotes a kept title verbatim:** `schedule-trigger.test.ts:71` points the reader at 「`ScheduleTrigger — the acting-organization refusal (objectstack-ai#16659)` below」, the exact text of the `describe` title at `:337`. The number there belongs to the quotation, so it stays with the title it quotes: rewriting it would point at a title that does not exist. It moves when the title does. - No source string, operator log string, assertion message, quoted maintainer ruling or generated file in this package carries a dead number. - Outside `src`, the package's `CHANGELOG.md` names `objectstack-ai#16659` on 6 lines and `objectstack-ai#16589` on 2 (lines 149, 153, 238, 273, 297, 300, 302, 304). It is release-owned and deliberately not edited here (see Acceptance notes). The package `README.md`, which also ships, names neither number. ## Mechanical guard: no code token moves The guard compares, base `cba417a8f` against head, over all 6 touched `.ts` files: - **Reading 1**, the TypeScript parser's leaf nodes (a `forEachChild` walk, so comments are trivia and JSDoc nodes are never visited). String and template literals are therefore read in full. - **Reading 2**, the full token stream in parser context (a `getChildren` walk, so punctuation and keywords are included; JSDoc nodes skipped). Results: - Real run: 10,192 base leaf tokens, **0 files with a token change** on either reading (exit 0). - Comment control in `schedule-trigger.ts` (「the same way `schedule` is.」 to 「the same way as `schedule`.」): 0 files changed, as expected (exit 0). - Positive control, a code token added in `time-relative-trigger.ts` (`resolveBindingOrganization(binding)` given `as FlowTriggerBinding`): DIFFER, 1,215 to 1,216 leaf tokens and 2,760 to 2,762 full tokens (exit 1). - Positive control, one digit changed inside a kept test title (`schedule-trigger.test.ts:462`, `objectstack-ai#16659` to `objectstack-ai#16658`): DIFFER on the string literal (exit 1). Every mutation went through `scripts/ablation-replace.mjs` (wrap mode) under a shell trap that restores by absolute path, and each landed (anchor 1 to 0, blob changed). Each restore was proven byte-identical to the HEAD blob (`651170483856`, `c85aadbd168d`, `78a5dea4a463`), with `git diff HEAD` empty and a clean tree afterwards. A first version of reading 2 used TypeScript's context-free scanner and was discarded before any control ran: it opened template tokens on backticks it could not place and swallowed comment text into them, so it reported comment edits as token changes (4 files) while reading 1 read 0. The parser-context stream replaced it, and every figure above is from the replacement. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/trigger-schedule` (`.changeset/20596-trigger-schedule-provenance-anchors.md`) is included. Its body is stage 11's, word for word, with the package name changed. Measured on the built package (A3): `files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is not private. After the build: - `ecdfc9411` appears 3 times in each of `dist/index.js` and `dist/index.mjs`: the inline comments at `schedule-trigger.ts:777` and `time-relative-trigger.ts:585` and `:702`, which the bundle keeps. - It appears twice in each of `dist/index.d.ts` and `dist/index.d.mts`: the `FlowTriggerBinding.organization` docblock (`schedule-trigger.ts:32`) and the sweep-context docblock (`time-relative-trigger.ts:50`). - `555a89cbd` appears once in each JS entry (`time-relative-trigger.ts:615`). - Positive controls, one unchanged line beside each shipped rewrite, land exactly where their neighbours do: four neighbours once in each JS file and 0 in the declaration files, and two once in each declaration file and 0 in the JS files. - A never-written negative phrase appears nowhere in `dist`. - Neither dead number is left in `dist`. ## Gates (head `14314f49c`) - **Citation judging, as CI runs it:** `pnpm check:issue-citations` exits 0. `node scripts/check-issue-citations.mjs` exits 0: the diff-scoped run judged 1 added citation across 2 files, the live `objectstack-ai#8844`, and it resolves. - **Doc authoring:** `pnpm check:doc-authoring` exits 0 (the sibling-package prose-id baseline holds, no growth). - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `14314f49c` (after a fresh fetch) derived 59 commands. They are all 53 derived at dispatch, plus `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. - Each ran with its exit code captured before any pipe, and all 59 exit 0. - `--ran`, fed each command with its exit code, reports 59 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. - A full `turbo run build` of `./packages/*` and `./packages/*/*` ran first under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an unbuilt workspace. - **Roster families the derivation lists outside its commands** (their rosters sit in directories this diff touches): `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit 0. - **Tests and typecheck, under the verify lock:** - `pnpm --filter @objectstack/trigger-schedule test`: 8 files pass and 170 tests pass. `vitest list --filesOnly` names 8 files, all the tracked test files, the 4 touched ones included. - `pnpm --filter @objectstack/trigger-schedule typecheck` exits 0, and `tsc --listFiles` holds all 14 files under `src/`, the 6 touched ones included. - **Lint, as a proven narrowing:** eslint with inline config disabled, over the 6 touched `.ts` files, gives 6 files, 0 errors and 0 warnings (its `--format json` output). All 6 are in eslint's own population (`isPathIgnored` is false for each; a `dist` file, as the control, is ignored). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, as its own lines 327-328 state), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. - **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of the 7 changed files for control bytes finds none. ## Acceptance notes - **The gate-invisible spellings, grepped as the claim asked.** `CITATION_RE` refuses a hyphen after the digits and a `/` before the `#` (objectstack-ai#20636), and `NON_CITATION_HEADS` excuses a number after the word 「option」. In this package: `#N-word` none, `#A/#B` none, `option #N` none, at the base and at the head, which is the claim's 0 / 0 / 0. The two `pre-objectstack-ai#10220` spellings in `time-relative-trigger.test.ts` are extracted by the gate as this repository's `objectstack-ai#10220`, which the census judges live. - **`CHANGELOG.md` is left.** `packages/triggers/trigger-schedule/CHANGELOG.md` names `objectstack-ai#16659` and `objectstack-ai#16589` on 8 lines. It is release-owned (AGENTS.md, Documentation Guardrails), a deferred surface of the citation gate, and ⛔ not part of this stage. - **「The card」 phrases are left.** 8 comment lines in 5 files of this package speak of 「this card」, 「that card」 or 「the card」. They carry no number and neither instrument sees them. The one beside a rewritten line, `schedule-trigger.test.ts:329` (「the card's consequence (3)」), sits under the heading `:327` that now names `ecdfc9411`, whose own message pins those three consequences, so it keeps a referent. The rest are unchanged, as in stages 8 to 11. - **The census instrument did not truncate in this stage.** Both enumerations read 186 pages at the newest frontier. - **Anchors the next stages can reuse**, each checked here: `objectstack-ai#16589` → `555a89cbd` is new to the sweep; `driver-memory`'s own `src` still names `objectstack-ai#16589` on 29 lines in 4 files (26 of them comments; corrected by the seat from the dev report, which measured it), all outside this lane's stage surface. `objectstack-ai#16659` → `ecdfc9411` reuses the spec and lint stages' anchor. - **Base.** The branch is on `main` at `cba417a8f`. `main` has since moved three commits (`0d9349fea`, `7053333e1`, `f284ab26d`). Their 9 files are one changeset, ADR-0053, and sources and tests under `service-analytics` and `service-automation`. They touch nothing under `trigger-schedule`, nor `scripts/check-issue-citations.mjs`, `.changeset/config.json` or the `doc-authoring-prose-id` baseline. `service-automation` is a dev dependency of this package, but this diff moves no code token, so nothing here can interact with it. No merge was taken; the merge queue rebuilds on the merged generation. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… commits and ADR that decided them (objectstack-ai#20816) Part of objectstack-ai#20596 Clause-②: no ## What changed This is the fourteenth stage of the `domain:services` lane of the dead-citation sweep. It covers `packages/services/service-automation/src/**` and nothing else. By the seat's claim (`5905919247`), it is the largest package left in the lane, and it was free once the `engine.ts` work of the previous holder landed as `c8111a575`. Later stages cover the other packages, so this PR says `Part of` and the card stays open. Every comment or docblock site in scope that cited a tracker number answering 404 has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), by the method of stages 1 to 13 (the latest is PR objectstack-ai#20789, landed as `8acdae9d8`). That is **73 sites on 73 lines in 27 files, covering 14 numbers**: - 44 census sites (every census site this package has at the base); - 24 sites in test comments, which the census defers; 3 of their numbers (`objectstack-ai#11504`, `objectstack-ai#16709`, `objectstack-ai#8778`) stand only in test files here, and each was read on its own and answers 404; - 5 sites the census grammar cannot see: the `objectstack-ai#13398-class` spelling (a hyphen after the digits), 2 in `engine.ts` and 3 in test files. Each rewritten line now cites the record in this repository that decided what the line describes, and says in its own words what was decided: **15 distinct commit shas, plus ADR-0126 §7.2** on 2 lines, per ruling C's order (the ADR first where it records the decision; see the per-number table). No number was dropped. Only comments changed. Every touched source file keeps its line count (76 lines out, 76 in, over 27 files), so no line citation into these files moves. 73 of the 76 changed lines carried a dead citation; the other three are listed under Wordings. No code token moves (see the guard below). **No citation number is added.** The only tracker numbers on added lines are the live `objectstack-ai#14095`, `objectstack-ai#14456`, `objectstack-ai#8287` and the cross-repo `hotcrm#1206`, each once and each on the line it already stood on. No number is new to the diff, no number grew, and no PR number is the citation on an added line. 17 dead sites are left on purpose: 16 test titles and 1 runtime string (see the list below). One more file: a `patch` changeset for `@objectstack/service-automation`, because the rewritten prose ships (see Changeset below). ## Census: `service-automation`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count below is its `allocated-but-absent` findings under `packages/services/service-automation/`. Each run counts as a reading only because its board frontier equals the newest issue or pull-request number, read by a separate request just before and just after the run. In two of the three runs a new number was opened while the run was enumerating; each frontier equals the newest number at the run's end, which is the criterion (stages 7, 11 and 13 met the same shape). | reading | tree | board | whole-repo `allocated-but-absent` | service-automation sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `8acdae9d8`, run 2026-09-30T07:03:52Z to 07:07:25Z | enumerated, 187 pages, frontier objectstack-ai#20798 (newest objectstack-ai#20796 before, objectstack-ai#20798 after) | 796 | **44** | 44 | 11 | 11 | | after | `3511e88cc` (comments and changeset), run 07:32:02Z to 07:35:27Z | enumerated, 187 pages, frontier objectstack-ai#20803 (newest objectstack-ai#20803 before and after) | 752 | **0** | 0 | 0 | 0 | | after, final head | head `a602c4003`, run 07:58:50Z to 08:02:19Z | enumerated, 187 pages, frontier objectstack-ai#20809 (newest objectstack-ai#20807 before, objectstack-ai#20809 after) | 752 | **0** | 0 | 0 | 0 | The whole-repo drop is 44, exactly this diff's census sites. The `resolves` tally is 33,096 in all three runs, and `resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (1,003) did not move either. No run was truncated or discarded: all three enumerations read 187 pages at the newest frontier. The seat's census counted 45 here at `6bff748b`. The difference is one `objectstack-ai#10243` comment line that `36d043be1` (PR objectstack-ai#20724) removed from `engine.ts` in the meantime; the other seven commits since then add or remove no dead site in this package's non-test `src`. **Supplementary instrument, the whole scope.** The census does not read test files or strings, and this stage's scope includes test comments. So a second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) and `namesThisRepository` over every `.ts` file under `service-automation/src` (191 files). It takes its verdicts from the before census's own board reading rather than from a second enumeration: a number is dead when that census reported it `allocated-but-absent`, and alive when the gate's own census-scope extraction judged it and the census did not report it. 39 numbers are covered by neither, because they stand only in test files or strings here; each was read on its own through the read-only tools (2 answer 404: `objectstack-ai#11504` and `objectstack-ai#16709`; 18 answer 200 as issues; 19 answer 200 as pull requests, 18 of them also the squash suffix of a commit on `main`). | reading | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---| | before, `8acdae9d8` | 3,019 | **85** | 44 | 24 | 1 | 16 | | after, `a602c4003` | 2,951 | **17** | 0 | 0 | 1 | 16 | Its src-comment column equals the census's 44, which is the control on the second instrument. The 2,874 live citations and 60 cross-repo citations are the same in both readings, and the drop of 68 citations is exactly the rewritten sites the gate's grammar can see. A third, raw reading (every `#` followed by 2 to 6 digits, whatever surrounds it) finds 3,078 occurrences before and 3,005 after: the 68, plus the 5 `objectstack-ai#13398-class` sites only this reading sees. ## Per-number table Sites and files count every dead occurrence in scope at the base (comments and strings, tests included, and the 5 hyphen-joined sites). `rewritten / left` counts the sites rewritten and the sites left. Each anchor was read in its message and diff, not only its subject. | number | sites / files | rewritten / left | anchor: what it decided | |---|---|---|---| | `objectstack-ai#11060` | 17/4 | 12/5 | `815585513` (PR objectstack-ai#11347): flow value expressions gain exactly `round` / `floor` / `ceil` / `abs` / `min` / `max`, mirrored 1:1 from the CEL stdlib, and an unknown name in call position becomes the loud `FlowExpressionFunctionError` instead of a silent `null`. Its message records the maintainer ruling on `objectstack-ai#11060` (2026-08-23, option A) and its diff names the number 18 times; `git blame` puts 11 of the 12 lines in it, and the twelfth (`end-node-refused-outcome.test.ts:333`) was written later. The lint lane's anchor for the same number | | `objectstack-ai#10243` | 14/5 | 13/1 | Three rungs, per line. **ADR-0126 §7.2** on 2 lines (`engine.ts:2315`, `flow-activation-ledger.test.ts:1024`): the durable ledger row replaces the process-local `flowEnabled` map, "retiring the objectstack-ai#10243 leak's mechanism rather than refining it" (`docs/adr/0126-packaged-metadata-customization-model.md:339-340`), which is what both lines say is the point. **`02b41232d`** (PR objectstack-ai#10996) on 9 lines, the ones that say the map "measured" leaking: the recorded measurement that tenant A's toggle answered 200 and tenant B and the platform admin read the flow back off. **`266436a7f`** (PR objectstack-ai#11660) on 2 lines: it implements the maintainer ruling on `objectstack-ai#10243` (option A, toggle joins the `manage_metadata` write set), which is the gate `flow-activation-store.ts:67` says the difference "turned on", and it wrote the "mitigating but not exculpating" record that `flow-activation-ledger.test.ts:272` quotes. The runtime lane's anchors for the same number | | `objectstack-ai#14419` | 14/4 | 11/3 | `c5a7448d5` (PR objectstack-ai#14948): `create_record` surfaces `engine.insert`'s classified `DUPLICATE_RECORD` code on the node result, the engine copies it onto `$error`, and `try_catch` preserves it across its own binding; deliberately scoped to `create_record`. Its message's last line names `objectstack-ai#14419` as its card, its diff names the number 13 times, and `git blame` puts 8 of the 11 lines in it (the other 3 were written by later commits it precedes). The spec lane's anchor for the same number | | `objectstack-ai#13398` | 9/4 | 9/0 | `e238c79f0` (PR objectstack-ai#13592): the earliest text in this repository that records the maintainer's published-sink ruling as made — raising a log level by widening a published sink that declares no `error` is "refused as actively harmful". Every line here states that ruling ("forbids raising a site to `error` where doing so means GROWING `error?` onto a published sink"). Stage 3's anchor, and the one the stage-3 review recommended for this package | | `objectstack-ai#16659` | 9/3 | 9/0 | `ecdfc9411` (PR objectstack-ai#17334): a time-triggered flow declares its acting organization on its start node, the engine lifts it onto the binding, and the triggers run the flow as it. `git blame` puts the 4 `engine.ts` / `suspended-run-store.ts` lines in it. The 5 lines in `notify-zero-delivery-visibility.integration.test.ts` were written by `ae6dcf6a4`, an ancestor of `ecdfc9411`, in the future tense ("once objectstack-ai#16659 lands"); they now name the landed commit (see Wordings). Stage 12's anchor | | `objectstack-ai#13648` | 9/3 | 6/3 | `7307191db` (PR objectstack-ai#14388): the public `resume` door normalises an absent signal to `{}`, and the chokepoints take a non-optional signal, so a signal-less resume is held to the screen contract. Its diff names `objectstack-ai#13648` 8 times; `git blame` puts 5 of the 6 lines in it | | `objectstack-ai#13681` | 6/4 | 4/2 | `18d816a50` (PR objectstack-ai#14452): the spec half of the contained-failure contract, which declares the run-level `failed`, the loop iteration through `try` / `catch`, and row identity on `$error`. Its subject names `objectstack-ai#13681`. The lines were written by the services halves (`d30ccb9bd`, `b7225477b`), both descendants. The spec lane's anchor for the same sentence ("one level up") | | `objectstack-ai#17123` | 4/2 | 2/2 | `ae6dcf6a4` (PR objectstack-ai#17339): a `notify` node reports `selected`, the recipients it addressed, so a zero-delivery run stops reading like a run with nothing to notify. Its diff writes `objectstack-ai#17123` 4 times, and `git blame` puts both lines in it. New to the sweep | | `objectstack-ai#8707` | 2/2 | 2/0 | `1408fe385` (PR objectstack-ai#8777): audit rows are stamped from the record's own organization. Stage 7's anchor | | `objectstack-ai#16709` | 2/1 | 1/1 | `8c7cca1ce` (PR objectstack-ai#16739): its item 1 pins the restore verb's drop of a stale hot consumed-suspension copy, and it created this test file. Stage 7's anchor | | `objectstack-ai#10062` | 1/1 | 1/0 | `fa5d137ab` (PR objectstack-ai#12942): published `src` may import only declared workspace dependencies; its diff moved this import to `@objectstack/metadata-core`. Subject names it | | `objectstack-ai#14390` | 1/1 | 1/0 | `9d7f7259f` (PR objectstack-ai#14603): both driver exits of `engine.update` answer a unique violation with the `DUPLICATE_RECORD` envelope. Subject names it. The runtime and rest lanes' anchor | | `objectstack-ai#11504` | 1/1 | 1/0 | `f90e82024` (PR objectstack-ai#12611): registers `FLOW_INPUT_SCHEMA_INVALID`, the line's subject; its diff names `objectstack-ai#11504` 5 times. The spec and runtime lanes' anchor | | `objectstack-ai#8778` | 1/1 | 1/0 | `7901b2dd2` (PR objectstack-ai#8905): the stamp-only `tenancy.organizationField` declaration the line names. Stage 6's anchor | Every cited sha matches exactly one commit (`git rev-parse --disambiguate`, count 1 for each of the 15), and all 15 are ancestors of the base (`merge-base --is-ancestor`, exit 0 for each; reverse leg, base against each anchor, exit 1 for each; control legs exit 0: stage 1's landing `422db788a`, and the repository's root commit, which lies deeper than every anchor; the history is complete, `--is-shallow-repository` false, 15,178 commits). Each of the 14 numbers answers 404 on the issues endpoint, which serves pull requests too, read one by one. No ADR, `scripts/adr-anchors/` file or other `docs/` page records the decision of any of the 14 (a `docs/audits` census row names `objectstack-ai#10062` as a gate's origin, and `docs/qa` checklist rows name `objectstack-ai#10243`; neither is a decision record), except ADR-0126, which records the retirement `objectstack-ai#10243`'s measurement led to (§7.2) and the operator gate (§5). The `objectstack-ai#10243` lines that describe the measurement or the ruling cite those commits; the two lines that describe the retirement cite the ADR. ## Wordings to check - **Tag swaps in brackets or parentheses.** 「[objectstack-ai#16659]」 became 「[commit ecdfc94]」 on 4 lines, 「[objectstack-ai#10062]」 became 「[commit fa5d137]」, and every parenthesised `(#N)` became `(commit SHA)` in place. - **Headings.** 「objectstack-ai#14419 —」, 「objectstack-ai#17123 —」, 「objectstack-ai#11060 —」, 「objectstack-ai#16709 item 1 —」 at the head of a docblock or comment became 「Commit c5a7448 —」 and so on, the form stage 13 used. - **Two headers name the card or issue by its commit.** `notify-zero-delivery-visibility.integration.test.ts:4` now opens 「The card behind commit ae6dcf6:」, and `flow-field-expression-scale.integration.test.ts:4` 「The end-to-end oracle for the issue behind commit 8155855」. The docblocks below them go on speaking of 「the card's reading」, 「the card's ⭐」 and 「the third value-producing surface the issue names」; the headers keep that antecedent. This landed as its own commit, `a602c4003`, and every reading was re-run on it. - **`objectstack-ai#13398`.** 「a objectstack-ai#13398-class raise: that ruling forbids」 became 「a raise under the published-sink ruling (commit e238c79): that ruling forbids」; 「outside objectstack-ai#13398's class」 became 「outside the sink ruling's (commit e238c79) class」; 「(objectstack-ai#13398-class)」 became 「(the published-sink ruling, commit e238c79)」. - **`objectstack-ai#10243`.** 「the map objectstack-ai#10243 measured leaking」 became 「the map commit 02b4123 measured leaking」 (and alike on 9 lines); 「the whole point of objectstack-ai#10243」 became 「the whole point of ADR-0126 §7.2」; 「the one objectstack-ai#10243 turned on」 became 「the one the toggle ruling (commit 266436a) turned on」; 「objectstack-ai#10243 — the retired mechanism is GONE」 became 「ADR-0126 §7.2 — the retired mechanism is GONE」. - **`flow-activation-ledger.test.ts:271-272`.** 「the objectstack-ai#10243 map's "cold boot reads enabled: true again" was recorded as mitigating-but-not-exculpating」 became 「the retired map's … was recorded (commit 266436a) as mitigating-but-not-exculpating」. The anchor moved one line down, onto the verb it dates; `:272` is one of the three changed lines that carried no dead number. - **`crud-nodes.ts:439-441`, a statement that was stale when it landed.** 「`engine.update` still leaks the raw driver error (objectstack-ai#14390, not yet fixed) — those node results have nothing structured to surface yet」 became 「`engine.update` gained the same `DUPLICATE_RECORD` envelope for a unique violation (commit 9d7f725), but those node results are untouched here — this repair was scoped to `create_record` alone.」 `9d7f7259f` is an ancestor of `c5a7448d5`, the commit that wrote the sentence, so the update door already carried the envelope when "not yet fixed" landed; `c5a7448d5`'s message states the `create_record`-only scope. `:439` and `:441` are the other two changed lines with no dead number. - **`objectstack-ai#16659` in the notify test, future tense.** 「Why objectstack-ai#16659 landing does not close this」 became 「Why commit ecdfc94 does not close this」; 「the shape a scheduled flow has once objectstack-ai#16659 lands」 became 「the shape a scheduled flow takes under commit ecdfc94」; 「as it fires once objectstack-ai#16659 lands」 became 「as it fires under commit ecdfc94」. - **`objectstack-ai#13681`.** 「the measurement these tests reproduce (objectstack-ai#13681) found」 became 「the measurement behind commit 18d816a, reproduced here, found」: the measurement was the card's, and `18d816a50` is the contract that answered it. - **`objectstack-ai#11060`.** 「the LOUD half of the objectstack-ai#11060 ruling」 became 「the LOUD half of the ruling commit 8155855 records」; 「the exact silence objectstack-ai#11060 removes」 became 「the exact silence commit 8155855 removed」; 「before objectstack-ai#11060 this wrote the field as undefined」 became 「before commit 8155855 …」. - **`objectstack-ai#14419`.** 「a different door than objectstack-ai#14419's original bug」 became 「a different door than the bug commit c5a7448 fixed」; 「the whole point of objectstack-ai#14419」 became 「the whole point of commit c5a7448」. - **`objectstack-ai#16709`.** 「objectstack-ai#16709 item 1 —」 became 「Commit 8c7cca1, item 1 —」: the item numbering is that commit's own. ## The 17 sites left - **Test strings, 16 sites on 16 lines**, all `describe` / `it` titles, left as stages 1 to 13 left theirs: `contained-failure-visibility.test.ts:184` and `loop-dying-body-steps.test.ts:298` (`objectstack-ai#13681`); `create-record-duplicate-code.test.ts:51`, `:133`, `:255` (`objectstack-ai#14419`); `notify-zero-delivery-visibility.integration.test.ts:403`, `:535` (`objectstack-ai#17123`); `screen-resume-signal-less.test.ts:81`, `:134`, `:187` (`objectstack-ai#13648`); `template-functions.test.ts:44`, `:162`, `:202` and `flow-field-expression-scale.integration.test.ts:83` (`objectstack-ai#11060`); `flow-activation-ledger.test.ts:1027` (`objectstack-ai#10243`); `stale-hot-consumed-suspension.test.ts:157` (`objectstack-ai#16709`). - **One runtime string**: `builtin/template.ts:192`, the tail of the unknown-function refusal ("(Before objectstack-ai#11060 this name was silently rewritten to null …)"). A runtime string takes form D, not this card's comment-only form C, and the shrink-only `doc-authoring-prose-id` baseline already holds it (`template.ts`: `objectstack-ai#11060: 1`), so `check:doc-authoring` sees no growth. - No operator log string, assertion message, quoted maintainer ruling or generated file in this package carries a dead number. - **Outside `src`, listed and left, not edited in this stage:** the shipping `README.md` names no dead number (`objectstack-ai#4336`, `objectstack-ai#4414`, both live). `tsconfig.test.json` names the dead `objectstack-ai#13176` on 2 lines (5, 73) and the dead `objectstack-ai#14916` on 1 line (54); its other numbers are live. `vitest.config.ts` names only live numbers. The release-owned `CHANGELOG.md` names 7 of the 14 numbers on 13 lines. ## Mechanical guard: no code token moves The guard compares, base `8acdae9d8` against head, over all 27 touched `.ts` files: - **Reading 1**, the TypeScript parser's leaf nodes (a `forEachChild` walk, so comments are trivia and JSDoc nodes are never visited). String and template literals are therefore read in full. - **Reading 2**, the full token stream in parser context (a `getChildren` walk, so punctuation and keywords are included; JSDoc nodes skipped). Results: - Real run at the final head `a602c4003`: 47,982 base leaf tokens, **0 files with a token change** on either reading (exit 0). - Comment control in `engine.ts` (「which folds nothing and rejects nothing.」 to 「which folds nothing and refuses nothing.」): 0 files changed, as expected (exit 0). - Positive control, a code token renamed in `engine.ts` (`function applyResumeSignal(` to `function applyResumeSignalX(`): DIFFER on the identifier (exit 1). - Positive control, one digit changed inside a kept test title (`flow-activation-ledger.test.ts:1027`, `objectstack-ai#10243` to `objectstack-ai#10244`): DIFFER on the string literal (exit 1). Every mutation went through `scripts/ablation-replace.mjs` (wrap mode) under a shell trap that restores by absolute path, and each landed (anchor 1 to 0, blob changed). Each restore was proven byte-identical to the HEAD blob (`26e9be7dc174`, `e78e505fa3be`), with `git diff HEAD` empty and a clean tree afterwards. The controls ran on `3511e88cc` and again on `a602c4003`. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/service-automation` (`.changeset/20596-service-automation-provenance-anchors.md`) is included. Its body is stage 4's (`plugin-security`, the other stage with an ADR anchor), word for word, with the package name changed. Measured on the built package (A3), after a full workspace build in which this package was a cache miss (71 of 71 tasks, at `9b0d34213`, which holds every source-line change): `files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is not private. - The declaration files `dist/index.d.ts` and `dist/index.d.cts` carry the rewritten docblocks at `engine.ts:354`, `:362`, `:529`, `:2112`, `:2119`, `:2315`, `:2331`, `:5224`, `:7984` and `flow-activation-store.ts:67` (e.g. `02b41232d` 4 times, `c5a7448d5` twice, `ecdfc9411` and `7307191db` once each). - The JS entries `dist/index.js` and `dist/index.cjs` carry the kept comments at `engine.ts:2315`, `:2331`, `:3565`, `:3581`, `:5224`, `:7984`, `notify-node.ts:449`, `suspended-run-store.ts:714` and `sys-automation-run.object.ts:112`. - The other rewrites are stripped by the bundle or sit in test files. - Positive controls, unchanged lines beside the rewrites, land exactly where their neighbours do: the line before `engine.ts:2112` and before `:2119` once in each declaration file and 0 in the JS; the `FlowActivationStore` docblock opener beside `flow-activation-store.ts:67` once in each declaration file; and the neighbours of three stripped rewrites (`crud-nodes.ts:438`, `suspended-run-store.ts:952`, `template.ts:24`) 0 everywhere. - A never-written negative phrase appears nowhere in `dist`. - None of the rewritten numbers is left in `dist`; the one `objectstack-ai#11060` in each JS entry is the kept runtime string at `template.ts:192`. The two commits after `9b0d34213` add the changeset and change two test-file comment lines, which the bundle does not include. ## Gates (final head `a602c4003`) - **Citation judging, as CI runs it:** `pnpm check:issue-citations` exits 0 (self-test, 114 cases, 8 batteries). `node scripts/check-issue-citations.mjs` exits 0: the diff-scoped run judged the 2 citations the change adds on its surface (the live `objectstack-ai#14095` and `objectstack-ai#8287`, each on the line it already stood on), and both resolve. - **Doc authoring:** `pnpm check:doc-authoring` exits 0 (the sibling-package prose-id baseline holds, no growth). - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `a602c4003` (after a fresh fetch) derived 63 commands. They are the 64 derived at dispatch less `pnpm check:error-code-casing`, which the derivation now lists as a roster family (below). - Each ran with its exit code captured before any pipe, and all 63 exit 0; none exited 3. - `--ran`, fed each command with its exit code, reports 63 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. - A full `turbo run build` of `./packages/*` and `./packages/*/*` ran first under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an unbuilt workspace. - The same 63 had also all exited 0 on `3511e88cc` before the referent commit. - **Roster families the derivation lists outside its commands** (their rosters sit in directories this diff touches): `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit 0. - **Tests and typecheck, under the verify lock, at `a602c4003`:** - `pnpm --filter @objectstack/service-automation test`: 157 files pass and 1,974 tests pass, every tracked test file under `src/`, the 16 touched ones included. - `pnpm --filter @objectstack/service-automation typecheck` exits 0 (`tsc --noEmit` plus the test-layer check on `tsconfig.test.json`). `tsc --listFiles` puts all 27 touched files in both programs. - **Lint, as a proven narrowing:** eslint with inline config disabled, over the 27 touched `.ts` files, gives 27 files, 0 errors and 0 warnings (its `--format json` output). All 27 are in eslint's own population (none reported ignored; a `dist` file, as the control, is ignored). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, as its own lines 327-328 state), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. - **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of the 28 changed files for control bytes finds none. ## Acceptance notes - **The gate-invisible spellings, grepped as the claim asked** (objectstack-ai#20636). At the base: `#N-word` 11 lines, of which the 5 `objectstack-ai#13398-class` lines were dead and are rewritten here; the 6 left are live (`objectstack-ai#5912` twice, `objectstack-ai#5048`, `objectstack-ai#5186`) or cross-repo (`hotcrm#548` twice). `#A/#B` 13 lines of the number-slash-number shape, plus 6 lines where the slash follows `ADR-0049` (`ADR-0049/objectstack-ai#1888`); every second number is live. `option #N` none. URL-spelled none. So the claim's 11 / 13 / 0 / 0 hold, and at the head the first is 6. - **A sibling of the `option #N` position, dormant.** `NON_CITATION_HEADS` also excuses a number after 「clause」, and `suspended-run-store-consume-log-cause.test.ts:408` reads 「The consequence clause objectstack-ai#6299 asked for」, a real citation of the live `objectstack-ai#6299`. Across `packages/**/src` there are 4 such sites, all in test files, a surface the gate defers anyway, so nothing dead hides there today. Recorded for objectstack-ai#20636's family, not filed. - **Two drifts left as they are, wording only.** `notify-zero-delivery-visibility.integration.test.ts:72` still calls the organization-less cron tick 「the shape production builds now」, written before `ecdfc9411` landed; it carries no number and lost no referent here. `suspended-run-store.test.ts:904` names `tenancy.organizationField`, which `502f179cc` has since retired from the authorable surface (stage 7 recorded the same drift in `plugin-approvals`). - **`update_record` still surfaces no `code`.** The corrected sentence at `crud-nodes.ts:439-441` is now true that `engine.update` carries the `DUPLICATE_RECORD` envelope while `update_record` does not surface it. That is an observation with no reported pull, recorded here. - **「The card」 phrases.** 134 comment lines in 50 files of this package speak of 「the card」 or 「this card」. They carry no number and neither instrument sees them. The two whose antecedent this diff would have removed are handled above; the rest are unchanged, as in stages 8 to 13. - **The census instrument did not truncate in this stage.** All three enumerations read 187 pages at the newest frontier. - **Anchors the next stages can reuse**, each checked here: `objectstack-ai#11060` → `815585513`; `objectstack-ai#10243` → `02b41232d` (the measurement), `266436a7f` (the ruling) or ADR-0126 §7.2 (the retirement), per line; `objectstack-ai#14419` → `c5a7448d5`; `objectstack-ai#13648` → `7307191db`; `objectstack-ai#13681` → `18d816a50`; `objectstack-ai#17123` → `ae6dcf6a4`; `objectstack-ai#14390` → `9d7f7259f`; `objectstack-ai#11504` → `f90e82024`; `objectstack-ai#10062` → `fa5d137ab`; and the reused `objectstack-ai#13398` → `e238c79f0`, `objectstack-ai#16659` → `ecdfc9411`, `objectstack-ai#16709` → `8c7cca1ce`, `objectstack-ai#8778` → `7901b2dd2`, `objectstack-ai#8707` → `1408fe385`. - **Base.** The branch is on `main` at `8acdae9d8`. `main` has since moved five commits (`41dcf1188`, `96e724475`, `df67985b0`, `cfa931535`, `5bed1f6ca`). None touches `packages/services/service-automation/src`, `scripts/check-issue-citations.mjs` or `.changeset/config.json`, and none is a path in this diff. Two of them move gate inputs (`scripts/doc-authoring-prose-id.baseline.json`, whose change is `driver-sql` rows only, and one `scripts/adr-anchors/` file for `packages/spec`), so those families ran here against the base's copies; this diff moves no code token and no runtime string, so nothing here can interact with them. No merge was taken; the merge queue rebuilds on the merged generation. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #16659
Clause-②: yes —packages/spec/src/automation/index.tsgains re-export lines and the generated surface gains five rows:SCHEDULE_ORGANIZATION_KEY,ScheduleOrganizationSchema, theScheduleOrganizationtype,resolveScheduleOrganizationanddescribeMissingScheduleOrganization. New exported symbols on a published entry point. One more published symbol lands on a different package:@objectstack/lintre-exportsFLOW_SCHEDULE_ORGANIZATION_MISSING.findScheduleOrganizationNearMissInConfigis folded into the sentence that was its only consumer (A1 below), so the count above is the one that freezes.⭐ Round 3 (
domain:specseat) — the merge, the two must-fix items, and the advisoriesWritten from session
session_01MkQhmuuJAVDjmeWNixwDDH; the contract review this rounddischarges is comment
5619271717on this PR, and the card's liveClaim:is5619338692. ⛔ Fenced to the merge and that review's findings — nothing about the designis this round's to change, and F7 plus the near-miss vocabulary gap stay out.
1. The merge — per-path preservation, proved
mergeable_statewasdirty. Merged (⛔ never rebased) throughscripts/pm/os-regen-merge.sh, with the risk list read from.gitattributesat mergetime: 18 routed patterns. Against merge base
e669e5eee, 8 routed paths were movedby this branch and 46 by
main— and exactly one by both:content/docs/references/index.mdx. That is the only path where themerge=os-regendriver could drop a side, and it did: after the merge the file carried this branch's
schedule-organizationrow and had silently lost main'smarketplacesection, exit 0,no conflict marker. Step 2 restored main's side; step 4 regenerated the whole chain on the
merged tree.
The proof, per routed path, each reading paired with a lit control (a path or marker
known to have moved, reading non-zero) and a dark control (a fabricated path or marker,
reading zero):
schedule-organization= 1 · main sectionMarketplace Protocol= 2 · main's removalCloud Protocol= 0Automation Protocol= 2Zzz Fabricated Protocol= 0origin/main—git diff --name-only origin/mainover that set is emptyapi-surface/automation.jsonlists itapi-surface/zzz-fabricated.jsonlists 0ScheduleOrganization/schedule-organizationall non-zero)api-surface/api.jsonreads 0 for the same markerZzzFabricatedSymbolinapi-surface/automation.jsonreads 0Both intents stack arithmetically in the regenerated index: 198 pages + 1 (this branch) − 11
(
cloudretired on main) + 4 (marketplacearrived) = 192, which is what the file says.Two non-generated conflicts were hand-resolved as semantic merges, both intents stacked:
packages/spec/llms.txt— 205 + 1 − 11 + 4 = 199, and the merged table sums to 199.packages/spec/src/type-alias-convention.pin.test.ts— 811 − 30 + 1 + 1 = 783,recomputed from the merged file by the test's own predicate rather than from either
side's arithmetic.
marker, leaving two declarations sharing the id
Iso871(main'sEnvironmentTypeSchemaand this branch's
ScheduleOrganizationSchema) — a duplicate-identifier error behind amerge that read clean. This branch's is renumbered
Iso872; ids are claims about pins,not positions.
2. M1 — the ADR-0087 disposition was wrong on substance. Fixed.
not-required (no-migration-prescription)→registered schedule-flow-acting-organization-required, one new entry underpackages/spec/src/migrations/entries/semantic/,gen:migration-registryrun. D3 coversexactly this case, and the changeset's body is a prescription in D3's sense. Direct
precedent:
rest-requireauth-default-flip(protocol 12) — behaviour-only, no shape moved,a deployment judgement no transform can make, registered anyway.
otherwise.
registry.ts'sstep18header states the convention in as many words:"v17.0.0 was cut before these narrowings landed, so their migration prescriptions belong
to the NEXT major … The enforcement itself ships earlier on the 17.x line (launch-window
convention: accept-set narrowings ride minor releases); this step is where
migrate metausers are told, at the major boundary where they look." The same-shaped sibling
18.cloud-subpath-retired(#16325) is filed there too.node scripts/check-adr-0087-registration.mjs --base origin/main, run post-commitbecause the gate reads changesets from git: exit 0, and it names the entry as new
here — i.e. registered by this PR, not claimed against a pre-existing one.
adr-0087: registeredmarker's remainder isparsed as a comma-separated id list, not as prose. Rationale that rides inside it reads
as several hundred nonexistent migration ids. The reasoning lives in the body instead.
3. M2 — the banner's single-organization sentence was false on
driver-memory. Fixed, and it is NOT only a sentence.Verified by running the driver's own exported predicate rather than a re-implementation
of it (
assertCallNotTenantScoped,packages/drivers/driver-memory/src/memory-tenancy-guard.ts),over all twelve doors it guards:
tenantIdpresent (the shape a DECLARED flow produces)MEMORY_MULTI_TENANT_UNSUPPORTEDtenantId(the shape a job tick produced before this change)The guard has no organization-count limb at all:
if (tenantId === undefined && tenantIds.length === 0) return;and a throw otherwise. So a single-organization install is not exempt, and the banner's
"That install loses nothing at run time" was false there. The sentence is now
driver-conditional and names the refusal.
⛔ And the seat's question, answered: it is not only a sentence. On
driver-memoryatime-triggered flow that touches genuinely per-organization data has no legal
configuration — declare an organization and every data node is refused per call; declare
none and the flow is not armed. The one configuration that is served, declaring the swept
objects platform-global (
tenancy: { enabled: false }, ADR-0066), is the one the guard'sown message says must not be reached for on data that really is per-organization. That is a
functional gap on a supported driver wearing a documentation defect's clothes. ⛔ This round
does not fix it — it is out of the fence, and the remedy is a design decision (does a
time-triggered flow on a store with no tenant isolation get an explicit exemption, or is
driver-memorydeclared unsupported for time-triggered data flows?). Reported and stoppedat that boundary; the banner and the migration entry both now say the gap exists rather
than implying it does not.
4. A1 — one export folded. Five, not six.
findScheduleOrganizationNearMissInConfigis no longer published. Both callers(
validate-flow-trigger-readiness.ts,schedule-trigger.ts) ran it only to hand the answerstraight to
describeMissingScheduleOrganizationon the next line, so the scan moved insidethat function, which now takes
describeMissingScheduleOrganization(flowName, { kind, config })and consults the vocabulary itself.
api-surface/export-origins/declaration-mapandthe reference page regenerated; the symbol reads 0 across all four generated surfaces,
with
describeMissingScheduleOrganizationstill present in two of them as the lit control.⭐ A
minorfreezes what it publishes, and removing an export later is breaking where addingone is not.
5. A2 — the name is KEPT, and the decision is a measurement, not a preference.
The question was whether
time_relativeis described as a "schedule" anywhereauthoritative. It is, in the place that governs authoring:
FlowSchema.typeisz.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api'])— there is no
time_relativeflow type. A time-relative sweep is authored astype: 'schedule'; the docs say so in as many words ("ascheduleflow whosestartnode declares a
timeRelativedescriptor") and therenewalReminderexample incontent/docs/automation/flows.mdxliterally setstype: 'schedule'.TimeRelativeTriggerSchema's own opening line: "Atime_relativetrigger sweeps anobject on a schedule (daily by default)".
resolveFlowTriggerKind's precedence note: a sweep "ALSO carries aschedulecadence(its sweep interval)", and it distinguishes a sweep from "a plain schedule flow" — a
split inside the schedule family, not out of it.
time_relative/timeRelativeacrossdocs/adr/*.md(lit control:
schedulematches many ADRs; dark control: a fabricated term matches none).The ADRs say nothing either way, so the enum and the docs are the authority.
⇒ Every flow this key applies to declares
type: 'schedule'.ScheduleOrganization…isaccurate for both subjects and is kept;
FlowActingOrganization…would have been a renameaway from the authorable vocabulary. Recorded in the module docblock so it is not
re-litigated after the freeze.
6. A4 / A5 / A6 — all three verified, all three now named
Each was checked against the tree before being written down, and each is in the banner
and in the migration entry's
replacement:sql-driver.tsscopes with(field = :tenantId OR field IS NULL), and this PR's own negative-control fixtureselects its
organization_id: nullrow (c3) under scope — the asserted set is['c1','c2','c3']. ⇒ after "one flow per organization", every such row is matched onceper flow: N runs, N notifications, each acting as a different organization. Survives.
scheduleDispatchKeyreturns`schedule:${flowName}:${window.startedAt}`and thesweep's key is
`time-relative:${flowName}:${scope}:${id}`. Both embed the flow name,so N differently-named flows claim under N different keys. Survives.
suspended-run-store.tsrehydrates withparseJson(row.context_json, {}); a row written before this change carries notenantId,and nothing back-fills it. Survives (not a regression — it is how the run already ran —
but the banner implied confinement without the carve-out).
7. Verification at head
18c0f6b44b@objectstack/spectesttype-alias-convention.pin.test.ts› "still declares all 783 isomorphic pins"@objectstack/linttest@objectstack/trigger-scheduletestpnpm --filter @objectstack/spec check:generatedcheck:react-declaration-parityMANIFEST=$PWD/sdui.manifest.jsoninvocation — measured, ⛔ not recorded as unrunnablepnpm lint(repo-wideeslint . --no-inline-config)check-clause2-carriers --pair 17334Gates.
dispatch-gatesderives 119 families for this change set; all 119 were runwith exit codes recorded and reconciled through
--ran: 117 run / 2 NOT MEASURED / 0UNRUN. The two are
check:dual-build-cjs-loadsandcheck:type-check-debt, both atexit 3 — PREREQUISITE NOT MET (each reads a complete repo build). ⛔ Neither is a pass
and neither is a finding; both are hosted by CI, which builds first.
check:skill-examplesrefused the same way on the first attempt and was then genuinelymeasured after building
@objectstack/client-react: exit 0, 258 prose examples across 3surfaces.
origin/mainmoved again while this round ran, sodispatch-gatesprints a STALETREE notice against the newest tip. The derivation above is against the tree at
18c0f6b44b; CI re-derives on the head it tests.⛔ Still owed, and not this round's to take: the contract-review verdict must be re-taken
on the head that lands — this round moved it — and
needs:contract-reviewstays on bothcarriers until the seat clears it.
is being re-filedsource comments(
examples/app-showcase/src/automation/flows/index.ts,examples/app-todo/src/flows/task.flow.ts) still carry no number. The backfill was fencedas opportunistic — only if you are touching those files anyway — and this round touches
neither. The changeset's copy of the sentence is updated to name #17396, because
the changeset was already being edited.
The ruling
Maintainer, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」
⭐ This round: the ruling's other half
The first head declared the acting organization and put it on the RUN. The adversarial review on #16659 (comment
5615359989) found the complementary half missing, and the maintainer ruled option A — complete the ruling. Thetime_relativesweep was still selecting across every organization:isSystemis authorization;tenantIdis tenancy. Passing only the first made this a cross-organization scheduled task with a declaration papering over it: a sweep declared for org A matched rows in org B and launched one run per match, each stamped A. Downstream that is worse than the defect the card opened on:update_recordon a B row matches nothing — silently;notifyposts into A's inbox about B's record — a cross-tenant disclosure that was previously refused outright, because an org-less run could write nowhere;The fix, and why this mechanism and not a predicate
The declared organization now rides the sweep's own query as
context.tenantId— the sameExecutionContextaxis the run already uses.Engine.buildDriverOptionsturns it intoDriverOptions.tenantIdand the driver scopes the read. Both members are keysEngineQueryOptions.contextalready accepts (ExecutionContextSchema.partial()), so namingtenantIdon the trigger's structural data-engine slice widens no contract; it declares the slice it uses.⛔ Not a hand-built
organization_idpredicate onwhere. That would be a second implementation of tenancy inside a trigger: it would hardcode a column the object is free to rename (tenancy.tenantField), select nothing on a platform-global object that has no such column, break a federated object outright, and — worst — read as a scoped query to a driver that never learned the caller wanted scoping, so a driver with no isolation would answer it silently instead of refusing it.Both new silences are closed, because that failure mode is this card's own subject
driver-memoryimplements no row-level tenant isolation and refuses anything handed a tenant scope (MEMORY_MULTI_TENANT_UNSUPPORTED, [finding] The InMemory driver silently ignores the engine's tenant scope for objects that OMIT atenancyblock — its guard only refuses an explicitenabled: true, so memory-driver runs show cross-organization rows a SQL driver refuses #16589). The sweep's failure is now logged aterrorrather thanwarn— stderr survives the CLI's boot-quiet window, and "this sweep can no longer see anything" became a reachable state the moment the query was scoped.tenancy.enabled: false(ADR-0066) or federated (ADR-0015) object the engine drops the scope, so the declaration cannot narrow that sweep at all — it still selects across every organization while its runs act as the declared one. The trigger says so at bind, atwarn, naming the object. ⛔ It does not pretend the flow is contained. That branch is a disclosure only: it never changes which rows come back, so a drift from the engine's predicate costs a wrong warning, never a wrong row.The rest of the review's blocking findings
system-write-organization.ts:268-273confirms the second). The banner now states the narrowing in two places — the bind-time accept set AND the run-time data plane, which no sentence in it previously mentioned — and carries the migration consequence an operator reads release notes for: a deployment running ONE time-triggered flow across ALL organizations must now declare one flow per organization.validate-flow-trigger-readiness.tsheld zero occurrences oforganization, againstengine.ts:21-27's own invariant. It now carriesflow-schedule-organization-missing, re-implementing no judgement:resolveFlowTriggerKindsays which flows owe the key,resolveScheduleOrganizationsays whether one was declared,describeMissingScheduleOrganizationwrites the sentence. Severitywarning, measured not argued — see below.suspended-run-store.ts: "a plain scheduled sweep has neither and keeps NULL") now says what happens, keeping the SUBJECT-first precedence it documents.origin/mainthroughscripts/pm/os-regen-merge.sh(⛔ no rebase). The one content conflict,type-alias-convention.pin.test.ts, was a semantic merge of two stacking intents — main's −4 (ui#6206-B, 815 → 811) and this branch's +1 (Iso871) — resolved to 812, recomputed from the file rather than by arithmetic, with both narratives kept. Generated artifacts were then regenerated wholesale on the merged tree: totals 198/1583 → 199/1584, automation 13/73 → 14/74.⛔ F7 was left alone, and so was the whole bind-time half: the review found it well built and genuinely pinned, and this round does not touch it. F6 is the PM's.
The severity decision was measured, not asserted
On the never-fire family's own criterion (#5762 — is this stack enough to know the flow is dead?) the new lint id belongs at
error. It is held atwarningbecause of the corpus, and that was run rather than reasoned: with the severity flipped toerror,objectstack buildonexamples/app-showcasefails, namingshowcase_task_due_reminderandshowcase_scheduled_digest. Neither can be repaired by authoring — the only legal value is asys_organization.idminted per install at runtime, and ⛔ a placeholder id is strictly worse than the omission. Promoting the id is a consequence of the open maintainer decision about package-shipped time-triggered flows, not a lint choice. The probe was restored byte-identically (blob == pre-probe blob) and both example builds pass atwarning, flagging exactly the four shipped flows.404, destroyed with the suspended account, and is being re-filed by the PM. This PR does not invent a number for it: the four in-source comments and the changeset carry the record instead, and the number is owed once the new card exists (grepis being re-filed).Verification
The differential control
⛔ A test that only proves "A's rows are found" does not distinguish the fix from the defect — the defect found them too, alongside B's. So the discriminating number is the count of launched runs.
packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.tsboots the real automation + messaging + ObjectQL stack with two organizations, one matching row in A and two in B, and atime_relativeflow declared for A. Onsqlite-wasm: exactly 1 run launches (3 is the unfixed reading),update_recordlands on A's row and no other, and nosys_notificationnames a B record. Onmemory: the scoped sweep is refused and reported aterror, with a paired control proving the refusal is about the scope and not about the fixture.Negative control — measured, not argued
Two legs over one fixture: the sweep as it ships (scoped) and the same query with
isSystemonly, which is the pre-fix sweep byte for byte. Asserted equal. The fixture carries a NULL-organization row on purpose — the driver's scope isorg = :tenant OR org IS NULL, so a platform row stays visible to a scoped read, and that is part of "identical" rather than an exception to it. Non-vacuous in both directions: the selected set is asserted to be the three rows, and leg A is asserted to have really asked for a scope, so this pin reddens under the same ablation as the differential instead of being one that can never fail. Its opposite limb is the DIFFERENTIAL above: add a second organization and the two legs must diverge.What a single-organization install does feel is the bind-time half — the flow is unarmed until it declares one line — and the changeset now says so instead of denying it.
Ablation — direction predicted in writing first, then measured
Reverting the sweep's context to the pre-fix bytes, in one shell under
trap … EXIT INT TERM, with the mutation proven on disk before either run (fixed spelling 1 → 0, ablated spelling 0 → 1,git diff --numstat= 1/1):['a1','b1','b2']-shaped: 3 runs), the notification disclosure, and the memory refusaltrigger-scheduleunitAs predicted,
the run acts on the DECLARED organization's recordstayed green under ablation — the run is scoped either way — which is exactly why the run count, not the write, carries the proof. One unit failure beyond the three enumerated: the unit twin of the memory-refusal pin, predicted for dogfood and omitted from the unit list.Restoration proven by state, not by exit code:
git diff HEADempty,git hash-object== the HEAD blob,git status --porcelainclean. Both suites resolve@objectstack/trigger-scheduleto source (dogfood's vitest alias + tsconfigpaths; the pair is absent fromKNOWN_UNALIASED_TEST_IMPORTS), so nodist/could retain the mutation.Full suites at the round-2 head
5fb332bdc(superseded — see Round 3 §7 for head18c0f6b44b)⛔ Reported as NEW numbers, not as confirmations: nothing on this branch had been run against that day's
mainat all.@objectstack/spectesttype-alias-convention.pin.test.ts› "still declares all 812 isomorphic pins" ✓@objectstack/service-automationtest@objectstack/trigger-scheduletest@objectstack/linttest@objectstack/dogfoodtestpnpm lint(repo-wideeslint . --no-inline-config)objectstack build, showcase + todoGates.
dispatch-gatesderived 117 families for this change set; all 117 were run with exit codes recorded and reconciled via--ran: 116 run / 1 NOT-MEASURED / 0 UNRUN. The one ischeck:dual-build-cjs-loadsat exit 3 — PREREQUISITE NOT MET (it reads a completepnpm build); ⛔ that is not a pass. It is hosted byci.yml'sbuild-corejob, and Build Core is green on this head, so the gate is measured — by CI, not locally. Chasing it locally package-by-package produced a different finding set on every run against a partial tree, naming only packages this diff never touches: a fact about build state, never about the diff.Scope
Against today's
origin/main(merge basee669e5eee): 42 files, +3045 / −53. The patch round itself is 15 files, +1273 / −65 on top of the original head —trigger-schedule's sweep and its pins, the dogfood differential control and its fixture, the lint rule and its suite, the changeset, the flows guide, the four example-flow comments, and one stale comment insuspended-run-store.⛔ Out of scope and untouched: the bind-time refusal, its pins, and the six spec exports the original head added.
Generated by Claude Code