Skip to content

fix(triggers,spec,service-automation)!: a time-triggered flow declares its acting organization and the run executes as it - #17334

Merged
os-bill merged 26 commits into
mainfrom
claude/issue-16659-schedule-trigger-acting-organization-r2
Sep 10, 2026
Merged

os-bill merged 26 commits into
mainfrom
claude/issue-16659-schedule-trigger-acting-organization-r2

Conversation

@os-tesla

@os-tesla os-tesla commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #16659

Clause-②: yes — packages/spec/src/automation/index.ts gains re-export lines and the generated surface gains five rows: SCHEDULE_ORGANIZATION_KEY, ScheduleOrganizationSchema, the ScheduleOrganization type, resolveScheduleOrganization and describeMissingScheduleOrganization. New exported symbols on a published entry point. One more published symbol lands on a different package: @objectstack/lint re-exports FLOW_SCHEDULE_ORGANIZATION_MISSING. ⚠️ It was six until this round — findScheduleOrganizationNearMissInConfig is folded into the sentence that was its only consumer (A1 below), so the count above is the one that freezes.

⚠️ This pull request is a re-creation, and its branch is a re-push. The original (#17126, branch claude/issue-16659-schedule-trigger-acting-organization) was removed when the os-trump account was suspended. That PR is now unreachable (404) yet still holds its branch, so GitHub refuses a second PR on it — hence the -r2 name.

The first 14 commits are byte-identical to the original patched head b998f58f3832d3f8f952172bbd9b28f668f18ed9. ⛔ No rebase, no amend, no squash; this round is append-only on top of it.

⛔ What was lost and is NOT reconstructed here: the original PR body, and the CONTRACT_REVIEW_TIER review thread that returned the first head CHANGES REQUIRED. This PR has had no at-tier review at all as far as the record now shows, so it needs a fresh one rather than a re-review.


⭐ Round 3 (domain:spec seat) — the merge, the two must-fix items, and the advisories

Written from session session_01MkQhmuuJAVDjmeWNixwDDH; the contract review this round
discharges is comment 5619271717 on this PR, and the card's live Claim: is
5619338692. ⛔ Fenced to the merge and that review's findings — nothing about the design
is this round's to change, and F7 plus the near-miss vocabulary gap stay out.

1. The merge — per-path preservation, proved

mergeable_state was dirty. Merged (⛔ never rebased) through
scripts/pm/os-regen-merge.sh, with the risk list read from .gitattributes at merge
time
: 18 routed patterns. Against merge base e669e5eee, 8 routed paths were moved
by this branch and 46 by main — and exactly one by both:
content/docs/references/index.mdx. That is the only path where the merge=os-regen
driver could drop a side, and it did: after the merge the file carried this branch's
schedule-organization row and had silently lost main's marketplace section, 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):

reading result lit control dark control
contested path carries both sides branch row schedule-organization = 1 · main section Marketplace Protocol = 2 · main's removal Cloud Protocol = 0 Automation Protocol = 2 Zzz Fabricated Protocol = 0
the 45 routed paths main moved and the branch did not byte-identical to origin/main — git diff --name-only origin/main over that set is empty the same command over api-surface/automation.json lists it the same command over a fabricated api-surface/zzz-fabricated.json lists 0
the 7 routed paths the branch moved and main did not every one still carries its branch content (ScheduleOrganization / schedule-organization all non-zero) api-surface/api.json reads 0 for the same marker ZzzFabricatedSymbol in api-surface/automation.json reads 0

Both intents stack arithmetically in the regenerated index: 198 pages + 1 (this branch) − 11
(cloud retired on main) + 4 (marketplace arrived) = 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. ⚠️ The text merge also took both sides' NEW pin with no conflict
    marker
    , leaving two declarations sharing the id Iso871 (main's EnvironmentTypeSchema
    and this branch's ScheduleOrganizationSchema) — a duplicate-identifier error behind a
    merge 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 under
packages/spec/src/migrations/entries/semantic/, gen:migration-registry run. D3 covers
exactly 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.

⚠️ Registered under protocol 18, not 17 — the dispatch said 17 and the tree says
otherwise. registry.ts's step18 header 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 meta
users 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-commit
because 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.

⚠️ Worth one line for the next author: the adr-0087: registered marker's remainder is
parsed 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:

the call the engine hands the driver result
scoped — tenantId present (the shape a DECLARED flow produces) 12 of 12 refused, MEMORY_MULTI_TENANT_UNSUPPORTED
unscoped — no tenantId (the shape a job tick produced before this change) 0 of 12 refused, 12 served

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-memory a
time-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's
own 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-memory declared unsupported for time-triggered data flows?). Reported and stopped
at 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.

findScheduleOrganizationNearMissInConfig is no longer published. Both callers
(validate-flow-trigger-readiness.ts, schedule-trigger.ts) ran it only to hand the answer
straight to describeMissingScheduleOrganization on the next line, so the scan moved inside
that function, which now takes describeMissingScheduleOrganization(flowName, { kind, config })
and consults the vocabulary itself. api-surface / export-origins / declaration-map and
the reference page regenerated; the symbol reads 0 across all four generated surfaces,
with describeMissingScheduleOrganization still present in two of them as the lit control.
⭐ A minor freezes what it publishes, and removing an export later is breaking where adding
one is not.

5. A2 — the name is KEPT, and the decision is a measurement, not a preference.

The question was whether time_relative is described as a "schedule" anywhere
authoritative. It is, in the place that governs authoring:

  • FlowSchema.type is z.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api'])
    — there is no time_relative flow type. A time-relative sweep is authored as
    type: 'schedule'; the docs say so in as many words ("a schedule flow whose start
    node declares a timeRelative descriptor"
    ) and the renewalReminder example in
    content/docs/automation/flows.mdx literally sets type: 'schedule'.
  • TimeRelativeTriggerSchema's own opening line: "A time_relative trigger sweeps an
    object on a schedule (daily by default)"
    .
  • resolveFlowTriggerKind's precedence note: a sweep "ALSO carries a schedule cadence
    (its sweep interval)"
    , and it distinguishes a sweep from "a plain schedule flow" — a
    split inside the schedule family, not out of it.
  • ADR text: zero mentions of time_relative / timeRelative across docs/adr/*.md
    (lit control: schedule matches 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… is
accurate for both subjects and is kept; FlowActingOrganization… would have been a rename
away 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:

  • A4 — a NULL-organization row fans out N-fold. sql-driver.ts scopes with
    (field = :tenantId OR field IS NULL), and this PR's own negative-control fixture
    selects its organization_id: null row (c3) under scope — the asserted set is
    ['c1','c2','c3']. ⇒ after "one flow per organization", every such row is matched once
    per flow: N runs, N notifications, each acting as a different organization. Survives.
  • A5 — the current window's dispatch claims are abandoned.
    scheduleDispatchKey returns `schedule:${flowName}:${window.startedAt}` and the
    sweep's key is `time-relative:${flowName}:${scope}:${id}`. Both embed the flow name,
    so N differently-named flows claim under N different keys. Survives.
  • A6 — a run suspended before the upgrade is not retroactively confined.
    suspended-run-store.ts rehydrates with
    parseJson(row.context_json, {}); a row written before this change carries no tenantId,
    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

suite result
@objectstack/spec test 470 files / 13238 passed — includes type-alias-convention.pin.test.ts › "still declares all 783 isomorphic pins"
@objectstack/lint test 103 files / 3724 passed
@objectstack/trigger-schedule test 5 files / 105 passed
typecheck, all three exit 0
pnpm --filter @objectstack/spec check:generated exit 0 — 15 of 15 artifacts current
check:react-declaration-parity exit 0, with the documented MANIFEST=$PWD/sdui.manifest.json invocation — measured, ⛔ not recorded as unrunnable
pnpm lint (repo-wide eslint . --no-inline-config) exit 0 in 110s — the full run, ⛔ not a narrowing
check-clause2-carriers --pair 17334 exit 0 — the declaration is readable in the fixed spelling and both carriers agree

Gates. dispatch-gates derives 119 families for this change set; all 119 were run
with exit codes recorded and reconciled through --ran: 117 run / 2 NOT MEASURED / 0
UNRUN
. The two are check:dual-build-cjs-loads and check:type-check-debt, both at
exit 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-examples refused the same way on the first attempt and was then genuinely
measured after building @objectstack/client-react: exit 0, 258 prose examples across 3
surfaces.

⚠️ origin/main moved again while this round ran, so dispatch-gates prints a STALE
TREE 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-review stays on both
carriers until the seat clears it.

⚠️ Not backfilled, deliberately: the two remaining is being re-filed source comments
(examples/app-showcase/src/automation/flows/index.ts,
examples/app-todo/src/flows/task.flow.ts) still carry no number. The backfill was fenced
as 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. The time_relative sweep was still selecting across every organization:

// before — time-relative-trigger.ts, the sweep's own query
await engine.find(desc.object, { where, limit: maxRecords, context: { isSystem: true } });

isSystem is authorization; tenantId is 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:

  • the run is scoped to A, so its update_record on a B row matches nothing — silently;
  • notify posts 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 history row is stamped SUBJECT-first, so it lands under B while the inbox rows sit under A. One run, three organizations' opinions about whose it was.

The fix, and why this mechanism and not a predicate

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. Both members are keys EngineQueryOptions.context already accepts (ExecutionContextSchema.partial()), so naming tenantId on the trigger's structural data-engine slice widens no contract; it declares the slice it uses.

⛔ Not a hand-built organization_id predicate on where. 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

  • A store that cannot honour the scope refuses the call. driver-memory implements 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 a tenancy block — its guard only refuses an explicit enabled: true, so memory-driver runs show cross-organization rows a SQL driver refuses #16589). The sweep's failure is now logged at error rather than warn — 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.
  • On a 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, at warn, 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 bind line now names the acting organization, so a sweep's reach is readable from the boot log.

The rest of the review's blocking findings

  • F1 / F3 — the changeset said something false, twice. It claimed "Nothing that was already delivering stops delivering" and, three paragraphs later, that on a single-organization install such a flow "was working" (system-write-organization.ts:268-273 confirms 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.
  • F4 — authoring learns the rule. validate-flow-trigger-readiness.ts held zero occurrences of organization, against engine.ts:21-27's own invariant. It now carries flow-schedule-organization-missing, re-implementing no judgement: resolveFlowTriggerKind says which flows owe the key, resolveScheduleOrganization says whether one was declared, describeMissingScheduleOrganization writes the sentence. Severity warning, measured not argued — see below.
  • F5 — the shipped examples. All four package-shipped time-triggered flows stop firing and now say so where an author meets them.
  • F8 — a comment this change made false (suspended-run-store.ts: "a plain scheduled sweep has neither and keeps NULL") now says what happens, keeping the SUBJECT-first precedence it documents.
  • F9 — sync and regenerate. Merged origin/main through scripts/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 at warning because of the corpus, and that was run rather than reasoned: with the severity flipped to error, objectstack build on examples/app-showcase fails, naming showcase_task_due_reminder and showcase_scheduled_digest. Neither can be repaired by authoring — 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 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 at warning, flagging exactly the four shipped flows.

⚠️ The card that carried that decision — #17150 — is 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 (grep is 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.ts boots the real automation + messaging + ObjectQL stack with two organizations, one matching row in A and two in B, and a time_relative flow declared for A. On sqlite-wasm: exactly 1 run launches (3 is the unfixed reading), update_record lands on A's row and no other, and no sys_notification names a B record. On memory: the scoped sweep is refused and reported at error, 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 isSystem only, which is the pre-fix sweep byte for byte. Asserted equal. The fixture carries a NULL-organization row on purpose — the driver's scope is org = :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):

suite ablated
dogfood sweep-scope 3 failed / 4 passed — DIFFERENTIAL (['a1','b1','b2']-shaped: 3 runs), the notification disclosure, and the memory refusal
trigger-schedule unit 4 failed / 100 passed

As predicted, the run acts on the DECLARED organization's record stayed 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 HEAD empty, git hash-object == the HEAD blob, git status --porcelain clean. Both suites resolve @objectstack/trigger-schedule to source (dogfood's vitest alias + tsconfig paths; the pair is absent from KNOWN_UNALIASED_TEST_IMPORTS), so no dist/ could retain the mutation.

Full suites at the round-2 head 5fb332bdc (superseded — see Round 3 §7 for head 18c0f6b44b)

⛔ Reported as NEW numbers, not as confirmations: nothing on this branch had been run against that day's main at all.

suite result
@objectstack/spec test 472 files / 13279 passed — includes type-alias-convention.pin.test.ts › "still declares all 812 isomorphic pins" ✓
@objectstack/service-automation test 128 files / 1521 passed
@objectstack/trigger-schedule test 5 files / 105 passed (99 → 105: six new pins)
@objectstack/lint test 103 files / 3714 passed
@objectstack/dogfood test 137 passed + 1 skipped files / 1087 passed + 3 skipped
typecheck, all five exit 0
pnpm lint (repo-wide eslint . --no-inline-config) exit 0, zero output — the full run, not a narrowing
objectstack build, showcase + todo exit 0, the new lint id warning on exactly the four shipped flows

Gates. dispatch-gates derived 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 is check:dual-build-cjs-loads at exit 3 — PREREQUISITE NOT MET (it reads a complete pnpm build); ⛔ that is not a pass. It is hosted by ci.yml's build-core job, 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 base e669e5eee): 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 in suspended-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

… 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
…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
@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/lint, @objectstack/dogfood, @objectstack/service-automation, @objectstack/spec, @objectstack/trigger-schedule, touching 27 documentable anchor(s). ⚠️ 11 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/qa/dogfood/package.json, packages/qa/dogfood/tsconfig.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 31064ca0ef30ed4c03482183f96baba11c8d0a76.

⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 11 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/qa/dogfood/package.json, packages/qa/dogfood/tsconfig.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 31 pages)
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

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

Which tree this was computed on

This run read content/docs from e59beff040bec2069e82a7470b5028335ccd6941 — the merge of head 18c0f6b44bb9ef31cacb0c3af02d65eb1093ab99 into base 31064ca0ef30ed4c03482183f96baba11c8d0a76, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

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

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
…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
@os-bill
os-bill marked this pull request as ready for review September 10, 2026 15:17
@os-bill
os-bill enabled auto-merge September 10, 2026 15:17

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

ACCEPT — at-tier PASS on the head that lands, gate cleared on both carriers, undrafted and enqueued

domain:spec execution seat, session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-10T15:17Z. Landing under the maintainer's direct instruction to carry this PR to merge.

The four landing pre-checks, on head 18c0f6b44b

# 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.

⚠️ Declared, because it would be wrong to let a reader infer otherwise: the re-review is seat-side at-tier, ⛔ not cross-seat independent — the reviewing subagent was dispatched by this seat and inherits its session id. The reviewer says so in its own comment.

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

@os-bill
os-bill added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit ecdfc94 Sep 10, 2026
46 checks passed
@os-bill
os-bill deleted the claude/issue-16659-schedule-trigger-acting-organization-r2 branch September 10, 2026 15:47
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants