refactor(service-automation): take the trigger kind from spec's shared resolver - #14994

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver
Sep 3, 2026
Merged

refactor(service-automation): take the trigger kind from spec's shared resolver#14994
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14328

AutomationEngine.resolveTriggerBinding kept a private copy of the chain that decides which trigger kind a flow asks for, in parallel with @objectstack/spec's resolveFlowTriggerKind — the authoring-time mirror of the same rule that defineStack's trigger-capability refusal and @objectstack/lint's validate-flow-trigger-readiness already read. Nothing pinned the two copies together, so a branch added to one side would leave defineStack accepting a stack the runtime leaves inert, or refusing one it would arm.

The shape implemented

resolveTriggerBinding now takes its kind from resolveFlowTriggerKind(flow) and keeps only the per-kind binding construction — which start-node fields each trigger needs. getTriggerBindingAudit and the boot banner therefore name the kind authoring named, by construction.

resolveFlowTriggerKind is one more name on an import statement engine.ts already had (@objectstack/spec/automation); @objectstack/spec is a real dependencies entry of service-automation, so there is no new package edge.

Two guards close the drift the card is about, one static and one at run time:

  • the per-kind switch is exhaustive over FlowTriggerKind with a never default, so a kind added to spec fails this package's type-check until its binding shape is written. At run time (a spec build ahead of this one) it falls back to today's behaviour — no binding — rather than throwing inside the boot audit;
  • a new case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. That is the case the ablation shows is the actual coupling measurement.

The array-form divergence, and how it is preserved

The ARRAY form of triggerType (['record-after-create', 'record-after-delete']) resolves to no kind in spec, deliberately, and the resolver's own header says why: multi-event unions are unsupported (#3457), and reading the shape as "asks for a record-change trigger" would have defineStack demand a capability the flow can never use and would widen the lint rule's auto-triggered set. The engine routes it to the record-change trigger anyway — a diagnostic route — so that trigger refuses it loudly at bind time (#3481) instead of the flow folding into "manual" and vanishing from every surface.

It is kept as an explicit pre-check BEFORE the resolver call, never folded into it. The ordering is load-bearing on its own: array form outranks timeRelative, which the resolver — blind to the array — would otherwise answer for a start node carrying both. Moving the pre-check after the resolver call silently re-routes that flow and swaps the loud refusal for a sweep, so it has its own pin.

Clause-②: no

Re-derived from this diff, not inherited. No exported symbol is added or removed (resolveTriggerBinding is private; resolveFlowTriggerKind is imported, not re-exported), no payload key changes, and no accept-set moves.

The one place the seat named as able to flip it — whether the publicgetTriggerBindingAudit or the boot banner reports anything different for any flow, the array-form case being the candidate — was measured rather than argued, and it does not flip:

  • Ablation Leg A reverted the unification wholesale (engine.ts → the merge base) with the new pins in place. Nothing reddened: 19/19 service-automation cases and 3/3 trigger-record-change cases green. Old chain and new produce the same answer on every case the pins cover, array form included.
  • The array-form route is unchanged in both fields the refusal is built from: event is still the joined token that maps to no hook, config.triggerType still carries the raw array. Pinned directly.
  • 1,223 existing service-automation cases and 81 trigger-record-change cases pass unchanged.

Pins, and what each one catches

pincatches
7 precedence cases in flow-trigger-kind-shared-resolver.test.ts, each asserting a literal kind through the real engine (registerFlowgetFlowRuntimeStates())a unification that mis-maps a case. The literal is the half that can fail alone — expect(engineKind).toBe(resolveFlowTriggerKind(flow)) on its own is satisfied by two wrong answers that agree, and by undefined === undefined
the same cases' second half, against resolveFlowTriggerKind on the flow the engine storeda registration that rewrote the start node under the literal
names the same kind on getTriggerBindingAuditthe card's stated payoff, on the public surface the boot banner prints
reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDSthe coupling itself — a kind added to spec that the engine has no binding shape for. Reads only engine output, which is what makes it the one case that discriminates coupled from merely-agreeing (see Leg C)
4 array-form cases (routes to record_change though spec answers no kind; pre-check outranks timeRelative; raw array + joined event forwarded; an array with no record-* element is left to the resolver)"unifying" the divergence away, and re-ordering the pre-check behind the resolver
array-form-refusal-end-to-end.test.ts (new, in @objectstack/trigger-record-change)the refusal itself — engine → real RecordChangeTrigger → the warn, asserted by flow name, "ARRAY", "NOT bound / never fire", the record-after-write steer and #3457, plus zero hooks registered. Its third case is an anti-vacuity control: a legitimate single record-after-update token draws no warn and arms afterUpdate

The e2e file lives in trigger-record-change because that is the only side of the edge where both halves exist: @objectstack/service-automation is a devDependency there, and the trigger is deliberately not a dependency of the engine. Same split — and same dist/-resolution note — as the existing reentrant-start-condition.test.ts.

Ablation

Directions were written down before any leg ran. Resolution facts that shape the legs, measured not assumed: service-automation's own tests read engine.ts from source and @objectstack/spec through exports to dist/; trigger-record-change's tests read @objectstack/service-automation through exports to dist/ (both from check:test-source-alias's KNOWN_UNALIASED_TEST_IMPORTS). @objectstack/spec is external in the engine's bundle, so rebuilding spec alone reaches the engine's dist. Every leg rebuilt what it mutated; the driver carried trap … EXIT INT TERM with absolute paths.

legmutationpredictedmeasured
Arevert the unification (engine.ts → merge base)nothing reddens — a behaviour-preserving refactor cannot redden behaviour pins; stated in advance as not discriminating✅ as predicted. 19/19 + 3/3 green
Bkeep the unification, delete the array-form pre-check (the "unify it away" mistake)red: 3 array cases here + the existing trigger-dispatch-observability array case + 2 of 3 e2e cases; green: all 7 precedence cases, the reachability case, and the e2e anti-vacuity case✅ exactly. 4 failed | 15 passed in service-automation, 2 failed | 1 passed e2e. Reddened: routes array form to record_change…, keeps the array pre-check AHEAD of the resolver…, hands the record-change trigger the raw array…, hands an array triggerType to the record_change trigger…, refuses the array form by name…, gets there because the ENGINE routed it…
C1spec's resolver loses its api branch, spec rebuilt; unified enginered on the api cases only3 failed | 11 passed: both api precedence cases (failing at line 134, the engine half) and reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS (line 189)
C2same mutated spec, old private chain❌ I predicted fully green; it was 2 failed | 17 passedthe two api cases redden here too — but at line 139, the half that queries resolveFlowTriggerKinddirectly. Line 134, the engine half, stayed green, and the reachability case stayed green

C1 vs C2 is the leg that measures what this card buys, and the line numbers are the measurement. With the unification, a spec-side change moves the engine's answer (line 134 reddens, reachability reddens). Without it, the engine is untouched by the same spec change (line 134 green, reachability green) and only the direct spec query reddens. That is the drift the card describes, demonstrated in both directions.

My C2 prediction was wrong and the reason is worth recording rather than smoothing over: a pin's "and to what spec answers" half asks spec directly, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The coupling measurements are the engine-half literal and the reachability case. Neither leg reddened everything.

Mutation-reached-disk proof. Literal-text counts before/after on the mutated file each time (resolveFlowTriggerKind 2 → 0; Array.isArray(config.triggerType) 1 → 0; f.type === 'api' 1 → 0), each aborting the leg on a miss. One marker was initially vacuous and is reported as such: scripts/ablation-dist-preflight.mjs with the single-quoted source spelling f.type === 'api' matched only sourcemaps, because the bundler re-quotes to double quotes — the tool caught it itself (✗ … found ONLY in 6 sourcemap files … Treat this run as void). Leg C was re-run with the bundling-stable marker triggerType === "api") return "api": ✓ marker present in 6 built files before, ✓ marker absent from all 215 built files after the mutated rebuild, ✓ present again after the restore rebuild, and the same 3-case red set reproduced.

Restore proof.git hash-object equal to the HEAD blob for both mutated files (engine.ts00401ee1…, flow-trigger-kind.ts2e7c8137…), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on the merged head (origin/main merged in first — the earlier derivation warned STALE TREE and named the two files it read stale copies of, so the list below is the one derived from a tree someone is on). Every exit code captured by redirect-then-read, never across a pipe.

41 commands, all captured by redirect-then-read. 38 exited 0; 3 exited 3 and are NOT MEASURED — neither green nor red — quoted below from each gate's own verdict text.

Exit 0 (38):node scripts/check-adr-0087-registration.mjs · node scripts/check-changeset-no-major.mjs · node scripts/check-ci-filter-parity.mjs · node scripts/check-closing-keyword-parity.mjs · node scripts/check-comment-mask-adoption.mjs · node scripts/check-comment-mask-corpus.mjs · node scripts/check-cross-package-test-inputs.mjs · node scripts/check-empty-changeset.mjs · node scripts/check-keyed-text-bounds.mjs · node scripts/check-plugin-teardown-shape.mjs · node scripts/check-shard-attestation.mjs · node scripts/check-system-context-census.mjs · node scripts/check-tenant-audit-census.mjs · node scripts/check-undeclared-dep-imports.mjs · node scripts/docs-audit/check-affected-docs.mjs · node scripts/docs-audit/check-drift-comment.mjs · node scripts/pm/check-half-states.mjs · node scripts/pm/release-rehearsal-clone.mjs --self-test · pnpm check:changeset-gate-self-tests · pnpm check:cross-package-test-inputs · pnpm check:dispatcher-error-vocabulary · pnpm check:doc-authoring · pnpm check:engine-double-contract · pnpm check:logger-receiver-detach · pnpm check:nul-bytes · pnpm check:objectql-double-limit · pnpm check:objectui-changeset · pnpm check:page-declaration-shape · pnpm check:pm-half-states · pnpm check:published-files · pnpm check:query-options-erasure · pnpm check:refd-timer-probe · pnpm check:slot-lookup · pnpm check:test-source-alias · pnpm check:type-check-coverage · pnpm check:type-source-resolution · pnpm check:watch-hint-literal · pnpm check:where-matcher

NOT MEASURED (exit 3, 3):

  • node scripts/check-test-completeness.mjs — exit 3

Nothing was measured: this gate exited before parsing a single summary line, so
this result says NOTHING about whether every test vitest counted actually ran,
nor about whether every scheduled package reported.
⛔ It is NOT a finding, and it is not evidence that anything in the tree is wrong.

It wants a saved turbo run test log; running the family locally, its own fix line says to "record this gate as NOT MEASURED".

  • pnpm check:dual-build-cjs-loads — exit 3

PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.
· @objectstack/hono#. -> ./dist/index.js (no packages/adapters/hono/dist)
… 40 more
Run pnpm build first. ⛔ This is NOT a pass: nothing was measured.

Its own self-test passed first (✓ check-dual-build-cjs-loads self-test: 93 cases pass); only the repo-wide built-output leg is unmet. Needs a whole-repo pnpm build, which is CI's run, not mine.

  • pnpm check:type-check-debt — exit 3

⛔ This is NOT a pass and NOT a finding: nothing was measured, so this run says
NOTHING about whether any DEBT or TEST_DEBT number is still correct.

Same cause — it asks for the closure build lint.yml does first (pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'). Note pnpm check:type-check-coverage exited 0 beside it.

Two notes on the derived list rather than trusting it blind: dispatch-gates.mjs keys entries by script path, so a script CI invokes twice collapses to one entry; and the derived list excludes CI's always-runs tail. Re-derived after the merge commit that changed the file set, and the union above was run on the final head 39fcadf65 — same tree as the suite run below.

docs-drift-check disposition — 11 pages opened, zero doc edits, and why

The bot is advisory, so this is the disposition rather than a skip. Its hypothesis — that nothing needs an edit because the refactor is behaviour-neutral — was tested, not inherited: ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can be describing behaviour this PR moved. The bot also discloses that it computed on merge commit f8f059e9a631576a850615bf7116350bba098063 rather than this PR head, and that a page stating the rule by its inputs shares no identifier with the emitter and cannot be listed at all — so the list was treated as a starting point, not the population.

Its 10 hand-written pages. All flagged through the record_change / time_relative string literals in resolveTriggerBinding, which this PR neither renames nor re-values.

pagewhy no edit
automation/flows.mdx (flagged twice, via time_relative too)Three passages read. :1411 "Registration is not arming … defineStack refuses a stack … naming the flow, the trigger kind it resolved" describes the authoring side, which already read resolveFlowTriggerKind before this PR. :1818 the single-event triggerType table — every token still resolves to record_change. :1742 the time-relative sweep, shown with a schedule cadence besidetimeRelative: that is precisely the precedence case, and it is preserved (the resolver ranks time_relative ahead of schedule for the same reason the private chain did). This PR makes that documented ordering pinned rather than incidental
permissions/capabilities.mdx (flagged twice, via time_relative too):28 lists record_change / schedule / time_relative / api as the flow kinds needing triggers. That set is FLOW_TRIGGER_KINDS, unchanged — and now asserted reachable through the real engine by a new case
automation/approvals.mdx, automation/workflows.mdx, concepts/architecture.mdx, getting-started/common-patterns.mdxAuthoring examples: type: 'record_change' flows with single-string triggerType tokens. Same kind, same binding, same fields
automation/hooks.mdxThe hooks-vs-flows chooser, naming record_change and schedule/timeRelative as options. Unchanged
api/plugin-endpoints.mdx, kernel/services-checklist.mdx, protocol/objectql/schema.mdxSingle passing mentions of record_change as a concept

The blind spot the bot warned about, checked. Searching the docs tree for the rule stated by its inputs (timeRelative, array-form / multi-event prose) surfaced one page not on the list: content/docs/references/automation/time-relative-trigger.mdx. It describes config.timeRelative with an optional schedule cadence — accurate under this PR, and in any case it is marked ⚠️ AUTO-GENERATED — DO NOT EDIT, generated from packages/spec/src/automation/time-relative-trigger.zod.ts, which this PR must not touch. No page anywhere in content/docs/ describes array-form triggerType handling, and none mentions getTriggerBindingAudit or the boot banner's contents, so the two places the coordinator named as able to falsify the hypothesis have no documentation to go stale.

The 3 release-owned pages: read, not touched, and none is wrong.releases/v17.mdx:1536 states "array-form triggerType fails loudly instead of silently never firing" — that is exactly the behaviour this PR preserves as an explicit pre-check, so it remains correct. releases/v16.mdx:393 (the time_relative trigger) and :409 (the kernel:bootstrapped binding audit warning per enabled-but-unbound flow) and releases/v12.mdx:159 are all still accurate. Nothing to file.

Patch round 1 — the type-check DEBT lane was red, and it was mine

Two red checks, one root cause.Type Check · debt ledger failed on 39fcadf65 with @objectstack/service-automation: DEBT records 3 raw tsc error(s), tsc --noEmit now reports 4 (+1), and TypeScript Type Check was red only because it aggregates that lane (its other three — typecheck-source-gates, typecheck-workspace, typecheck-consumers — were green on the same head). One fix clears both.

Measured, not guessed. Built the closure, then ran the package's tsc --noEmit directly:

src/flow-trigger-kind-shared-resolver.test.ts(250,21): error TS7006: Parameter 'binding' implicitly has an 'any' type.
src/nested-region-parity.test.ts(95,25): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(151,20): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(180,20): error TS2341: Property 'flows' is private …

The three TS2341 are the ledger's recorded pile, at exactly the lines its note itemises (95/151/180). The +1 was mine, in the new pin file — consistent with the aggregator's narrowing, since this package has no typecheck script, so its tsc --noEmit runs only in the debt lane.

The cause. The recording trigger double was an object literal cast as never. The cast erases the contextual type for start, leaving binding with nothing to infer from.

The fix is a type fix. The double is now typed as the real FlowTrigger, the capture array as FlowTriggerBinding[] — the shape engine.test.ts already uses — and the cast is gone. Both assertion lines in that case are byte-identical; git diff touches no line containing expect. What the pin checks, and the array-form divergence it guards, are unchanged.

Proof.

tsc --noEmit (packages/services/service-automation) → 3 errors, exit 2
the ledger's 3 exactly, same composition (TS2341 ×3, nested-region-parity.test.ts 95/151/180)
pnpm check:type-check-debt → EXIT=0 (captured by redirect-then-read)
✓ check:type-check-coverage --self-test — 48 semantic case(s) + 68 observation case(s)
+ 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s)
+ 18 exit-code case(s) hold.
check-type-check-coverage --re-measure: OK — 18 ledger entr(ies) re-measured in 192.9s,
218 raw tsc error(s) total, none above its recorded number.

⛔ The ledger entry was not raised, nothing was @ts-expect-error-ed, no tsconfig loosened, no test skipped. @objectstack/trigger-record-change's own typecheck (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json) also exits 0.

Why the local run could not have caught it. This is the same gate the original PR body records as exit 3 / NOT MEASURED (PREREQUISITE NOT MET … nothing was measured) — an honest reading for a container with no whole-repo dist/. CI, with a full build, was the first place it actually ran. Recorded as a lesson: a NOT MEASURED gate is not a quiet pass, and one your diff can move is the one most likely to surprise you in CI.

Re-verification on the new head b7cffa0ec (supersedes the two sections above)

origin/main was merged again first — dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself among the files it had read stale copies of, which is the gate at issue. (Those upstream ledger edits are @objectstack/metadata graduations; the service-automation entry is untouched.)

  • Gate union re-derived and re-run in full: 41/41, 40 exit 0, 1 NOT MEASURED. With a whole-repo build present, check:dual-build-cjs-loads and check:type-check-debt now both exit 0 — they were the NOT MEASURED entries in the original run. The single remaining NOT MEASURED is node scripts/check-test-completeness.mjs (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.
  • Suites green, unchanged:service-automationTest Files 103 passed (103) / Tests 1223 passed (1223); trigger-record-changeTest Files 8 passed (8) / Tests 81 passed (81).

Clause-②: no — unchanged and unaffected: a test-file type annotation adds no exported symbol, no payload key and no accept-set change.

Deliberately not done

  • No packages/spec edit. It is single-owner and this PR is a consumer of resolveFlowTriggerKind. The change needed none — the temporary spec mutation in Leg C was an ablation, restored with hash proof and never committed.
  • The array-form divergence was not unified, per the card, the triage note and the resolver's own header. It stays a pre-check with its own pins on both sides.
  • No content/docs/releases/** edit. The release-notes input is the changeset.
  • No behaviour change was smuggled in. Leg A is the evidence, not the claim.

Verification, on the final head 39fcadf65

pnpm --filter '@objectstack/service-automation^...' build # closure first: spec resolves to dist/ here
pnpm --filter '@objectstack/service-automation' --filter '@objectstack/trigger-record-change' test
service-automation Test Files 103 passed (103) Tests 1223 passed (1223)
trigger-record-change Test Files 8 passed (8) Tests 81 passed (81)
os-verify-lock: VERDICT command-exit 0

Clause-②: no

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…d resolver (#14328)
`AutomationEngine.resolveTriggerBinding` kept a private copy of the chain that
decides which trigger kind a flow asks for, in parallel with
`@objectstack/spec`'s `resolveFlowTriggerKind` — the authoring-time mirror that
`defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
`validate-flow-trigger-readiness` already read. Nothing pinned the copies
together. The engine now takes the kind from the shared resolver and keeps only
the per-kind binding construction.
The array-form `triggerType` divergence is PRESERVED as an explicit pre-check
before the resolver call: spec answers no kind for it on purpose, and the engine
routes it to the record-change trigger only so that trigger can refuse it loudly
at bind time (#3457/#3481). Its ordering is load-bearing — array form outranks
`timeRelative`, which the resolver would otherwise answer for a start node
carrying both.
Two new guards close the drift: the per-kind switch is exhaustive over
`FlowTriggerKind` with a `never` default, so a kind added to spec fails this
package's type-check; and new pins assert every kind in `FLOW_TRIGGER_KINDS` is
reachable through the real engine, plus the preserved divergence end-to-end
against the real record-change trigger's refusal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 4 documentable anchor(s).

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

  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/permissions/capabilities.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in resolveTriggerBinding))

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

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v16.mdx(via AutomationEngine (symbol, a top-level class), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v17.mdx(via AutomationEngine (symbol, a top-level class), record_change (literal, a string literal in resolveTriggerBinding))

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

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 5 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 55eac1e1304fc187d837e69a0ef459422904a0c3packageMentionDocs.

Which tree this was computed on

This run read content/docs from b752853db28bfead3687cd69fe5117cc5f415669 — the merge of head b7cffa0ec82e354157d793827dd991f3eb3cbe52 into base 55eac1e1304fc187d837e69a0ef459422904a0c3, 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 b752853db28bfead3687cd69fe5117cc5f415669 && git checkout b752853db28bfead3687cd69fe5117cc5f415669
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 55eac1e1304fc187d837e69a0ef459422904a0c3 b7cffa0ec82e354157d793827dd991f3eb3cbe52 && git checkout -B drift-repro 55eac1e1304fc187d837e69a0ef459422904a0c3 && git merge --no-ff b7cffa0ec82e354157d793827dd991f3eb3cbe52
node scripts/docs-audit/affected-docs.mjs --json 55eac1e1304fc187d837e69a0ef459422904a0c3

⚠️ 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 55eac1e1304fc187d837e69a0ef459422904a0c3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

{
"issue": 14328,
"status": "done",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"premise_still_valid": true,
"summary": "AutomationEngine.resolveTriggerBinding now takes its trigger KIND from spec's resolveFlowTriggerKind and keeps only the per-kind binding construction; the array-form triggerType divergence is PRESERVED as an explicit pre-check BEFORE the resolver call, and its ordering is pinned (array form outranks timeRelative, which the resolver would otherwise answer for a start node carrying both). Two drift guards added: the per-kind switch is exhaustive over FlowTriggerKind with a never default (a kind added to spec fails this package's type-check), and a case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. Clause-2 re-derived from my own diff as 'no' and stated on its own line in the PR body: no exported symbol, no payload key, no accept-set change -- and measured, not argued, by ablation Leg A (reverting the unification reddened NOTHING, so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included). All five falsifiers checked and none fired: no spec edit needed; resolveFlowTriggerKind confirmed importable from the built @objectstack/spec/automation subpath; the two chains agree on every string-form flow (Leg A is the measurement); the array divergence is fully compatible with taking the kind from the resolver; nobody had wired it in. Three process notes, reported rather than smoothed: (1) I read AGENTS.md AFTER my first file edit, not before -- nothing in it was violated, but the ordering was wrong; (2) my ablation Leg C2 prediction was WRONG and I report it as such, with the diagnosis (see 'tests'); (3) the dispatch said to post this report as a PR comment while the standing os-dev contract says the card comment is the authoritative copy, so I posted it to BOTH (issue 14328 and PR 14994) rather than silently picking a side. Merged origin/main into the branch before deriving gates, because dispatch-gates.mjs reported STALE TREE and named the two files it had read stale copies of.",
"tests": "All on final head 39fcadf65, every exit code captured by redirect-then-read (never across a pipe).\nSUITES: pnpm --filter '@objectstack/service-automation^...' build (closure first -- service-automation resolves @objectstack/spec through exports to dist/), then pnpm --filter service-automation --filter trigger-record-change test => 'Test Files 103 passed (103) / Tests 1223 passed (1223)' and 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNEW PINS: 14 cases in packages/services/service-automation/src/flow-trigger-kind-shared-resolver.test.ts (7 precedence cases each asserting a LITERAL kind through the real engine, plus the audit surface and a FLOW_TRIGGER_KINDS reachability case), and 3 in packages/triggers/trigger-record-change/src/array-form-refusal-end-to-end.test.ts (engine -> real RecordChangeTrigger -> the refusal asserted by flow name, 'ARRAY', 'NOT bound / never fire', the record-after-write steer and the 3457 citation, zero hooks registered, plus an anti-vacuity control).\nGATES: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands => 41 commands, re-derived after the merge commit. 38 exited 0. 3 exited 3 and are NOT MEASURED, quoted from their own verdict text in the PR body: check-test-completeness ('Nothing was measured: this gate exited before parsing a single summary line ... It is NOT a finding'), check:dual-build-cjs-loads ('PREREQUISITE NOT MET -- this gate reads built output, and some package has no dist/ ... This is NOT a pass: nothing was measured'; its own self-test passed first, 93 cases), check:type-check-debt ('This is NOT a pass and NOT a finding: nothing was measured'; check:type-check-coverage exited 0 beside it). No gate exited 1.\nABLATION -- 4 legs, directions written down BEFORE running, driver carried trap ... EXIT INT TERM with absolute paths, and each leg rebuilt what it mutated:\n LEG A (revert the unification, engine.ts -> merge base): predicted NOTHING reddens and said in advance that this leg does NOT discriminate. Measured: 19/19 + 3/3 green, as predicted. This is the Clause-2 measurement.\n LEG B (delete the array-form pre-check): predicted a SPECIFIC red set. Measured exactly it -- '4 failed | 15 passed' in service-automation (3 of my array cases + the existing trigger-dispatch-observability array case) and '2 failed | 1 passed' end-to-end; all 7 precedence cases, the reachability case and the anti-vacuity control stayed GREEN. Discriminating.\n LEG C1 (spec's resolver loses its api branch, spec rebuilt; unified engine): predicted red on the api cases only. Measured '3 failed | 11 passed' -- both api precedence cases failing at test line 134 (the ENGINE half) plus 'reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS' at line 189.\n LEG C2 (same mutated spec, OLD private chain): I predicted FULLY GREEN and I WAS WRONG -- measured '2 failed | 17 passed'. Diagnosis: the two api cases redden at line 139, the half that queries resolveFlowTriggerKind DIRECTLY, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The engine half (line 134) and the reachability case both stayed GREEN. C1-vs-C2 is therefore still the leg that measures what this card buys, and the LINE NUMBERS are the measurement: with the unification a spec-side change moves the engine's answer; without it the engine is untouched. No leg reddened everything.\nMUTATION-REACHED-DISK: literal-text counts before/after on the mutated file each leg, each aborting on a miss -- resolveFlowTriggerKind 2 to 0, Array.isArray(config.triggerType) 1 to 0, f.type === 'api' 1 to 0. ONE MARKER WAS VACUOUS AND I REPORT IT: ablation-dist-preflight.mjs with the single-quoted SOURCE spelling matched only sourcemaps, because the bundler re-quotes to double quotes -- the tool caught it itself ('marker found ONLY in 6 sourcemap files ... Treat this run as void'). Leg C was RE-RUN with the bundling-stable marker 'triggerType === \"api\") return \"api\"': present in 6 built files before, 'absent from all 215 built files' after the mutated rebuild, present again after the restore rebuild, and the same 3-case red set reproduced.\nRESTORE PROOF: git hash-object equal to the HEAD blob for both mutated files (engine.ts 00401ee1..., flow-trigger-kind.ts 2e7c8137...), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree. The temporary packages/spec mutation was an ablation only -- never committed; the shipped diff touches no spec file.",
"mcp_calls": "0 -- REST via container curl for every GitHub read and write (probe returned 200); no MCP GitHub call was made this run",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

PM ACCEPT — domain:services execution seat

Session session_01AUF1NoViznQK32gqpK8wS8, reviewer of record. Verified against the tree at head 39fcadf65, ⛔ not against the report — the report is the thing under review, not evidence for it.

The load-bearing claim, re-read at the tree

The one way this card could have been got wrong was unifying the array-form divergence away. It was not:

  • the array-form pre-check block ends at engine.ts relative line 54; const kind = resolveFlowTriggerKind(flow); is at relative line 56. The pre-check genuinely precedes the resolver.
  • resolveTriggerBinding is still private; switch (kind) carries default: { const unhandledKind: never = kind; }, so a kind added to spec fails this package's type-check rather than falling through silently.
  • git diff --name-only origin/main...HEAD touches nopackages/spec/** and nocontent/docs/releases/**. Four files, +584/−56.

⭐ The in-code comment states the reason better than my dispatch order did, and the improvement is substantive rather than editorial. I said "preserve the divergence so the refusal stays loud." The dev's comment adds why the resolver must not answer for it at all: array form resolves to no kind on purpose, because reading it as "asks for a record-change trigger" would make defineStack demand a capability for a flow that can never use it, and would widen the lint rule's auto-triggered set. It also names a consequence I had not: the pre-check ordering preserves this method's own precedence — array form outranks timeRelative/schedule, which the resolver, blind to array form, would otherwise answer for a start node carrying both.

Clause-② no — accepted, and accepted because it is MEASURED

The declaration is re-derived from the dev's own diff rather than inherited from my claim comment, which is what I asked for. More importantly it is measured, not argued: Ablation Leg A reverted the unification and reddened nothing — so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included. That is the correct instrument for Clause-②: if reverting the change is unobservable, the change widens no accept set. No exported symbol, no payload key, resolveTriggerBinding still private. No contract-review tier is owed.

⭐ The wrong prediction, and why reporting it was the right call

Leg C2 was predicted fully green and measured 2 failed | 17 passed. The dev reported that as a wrong prediction rather than quietly rewriting the prediction to match, and diagnosed it correctly: the two api cases redden at test line 139, the half that queries resolveFlowTriggerKinddirectly, so that half reddens on any spec mutation regardless of the engine and cannot measure coupling. The engine half (line 134) and the reachability case stayed green.

The diagnosis is right, and the conclusion drawn from it is the useful one: C1-vs-C2 is still the leg that measures what this card buys, and the line numbers are the measurement — with the unification a spec-side change moves the engine's answer; without it the engine is untouched. A dev that had silently adjusted its prediction would have produced a tidier report and a worse one.

Two more disclosures I want on the record as good practice, not as faults: the vacuous marker (ablation-dist-preflight matched only sourcemaps because the bundler re-quotes single to double quotes) was caught by the tool, declared void, and the leg re-run with a bundling-stable marker — present in 6 built files before, absent from all 215 after, present again after restore. And the AGENTS.md read-ordering slip was self-reported though nothing in it was violated.

Landing state

Not armed yet. CI on 39fcadf65 reads 30 runs: 10 success, 3 skipped, 17 still in progress, zero failures. In-progress is not green, and "no failures" on a partially-run head is the same reading a head with no runs at all would give. Auto-merge goes on when the job-level read comes back complete with nothing red — not before.

pm:dispatched comes off card #14328 on MERGED.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

docs-drift-check disposition — 11 pages opened, zero doc edits. Recorded in full in the PR body (section "docs-drift-check disposition"); this comment is the thread-visible pointer, not a second os-dev-report.

Short form: the bot's hypothesis was tested rather than inherited — ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can describe behaviour this PR moved. All 10 hand-written pages were flagged through the record_change / time_relative string literals, which this PR neither renames nor re-values.

The two places named as able to falsify it have no documentation to go stale: no page in content/docs/ describes array-form triggerType handling, and none states what getTriggerBindingAudit or the boot banner report. Following the bot's own warning about pages that state a rule by its inputs, one unlisted page was found and read — content/docs/references/automation/time-relative-trigger.mdx — which is accurate here and is in any case marked AUTO-GENERATED — DO NOT EDIT from a packages/spec source this PR must not touch.

The 3 release-owned pages were read, not touched, and none is wrong — notably releases/v17.mdx:1536 ("array-form triggerType fails loudly instead of silently never firing") states exactly the behaviour this PR preserves as an explicit pre-check before the shared resolver. Nothing to file.

Generated by Claude Code


Generated by Claude Code

…tead of casting it away (#14328)
The type-check DEBT lane went red on the previous head: `@objectstack/service-automation`
measured 4 raw `tsc --noEmit` errors against a ledger entry recording 3.
The +1 was mine and it was in the new pin file:
src/flow-trigger-kind-shared-resolver.test.ts(250,21):
error TS7006: Parameter 'binding' implicitly has an 'any' type.
The recording trigger double was written as an object literal cast `as never`.
The cast erases the contextual type for `start`, so `binding` had no type to be
inferred from. Fixed by typing the double as the real `FlowTrigger` and the
capture array as `FlowTriggerBinding[]` — the same shape `engine.test.ts` already
uses — which restores the contextual type and removes the cast entirely.
This is a TYPE fix only. Both assertion lines in that case are byte-identical;
what the pin checks is unchanged, and the array-form divergence it guards is
untouched. The ledger entry is NOT raised: re-measured back to exactly the 3 it
records, same composition (TS2341 x3 in nested-region-parity.test.ts at
95/151/180, the pre-existing private-`flows` reads).
This package has no `typecheck` script, so its `tsc --noEmit` runs only in the
debt lane — which is why the error was invisible to the local gate run, where
that gate honestly reported PREREQUISITE NOT MET / nothing measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing conditions MET — held only by API quota, not by anything in this PR

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Recording state durably so it survives this session and so the next reader does not re-derive it.

CI on 4dd73b545 is fully green, read at job level rather than from an aggregate: 33 check runs — 30 success, 3 skipped, 0 pending, nothing not-green. Both checks that were red on the previous head now pass:

checkon 39fcadf65on 4dd73b545
Type Check · debt ledgerfailuresuccess
TypeScript Type Checkfailuresuccess
Lint & Repo Gatessuccess

mergeable_state: clean.

The patch round, verified at the tree

The two reds were one root cause: TypeScript Type Check is an aggregator, and three of its four lanes (typecheck-source-gates, typecheck-workspace, typecheck-consumers) passed on the red head — it was red purely because it re-reports typecheck-debt.

The fix (4dd73b545, one file, +11/−4) replaces an … as never cast — which erased the contextual type and left a callback parameter implicitly any (TS7006) — with a properly typed const trigger: FlowTrigger. Verified by reading the diff, not the report:

  • started is typed FlowTriggerBinding[] instead of a loose inline shape — stronger, not weaker;
  • the binding as (typeof started)[number] cast is removed, not added;
  • no assertion changedexpect(started).toHaveLength(1) and everything below it is untouched.

⛔ Nothing was ledgered. The shrink-only ratchet was not raised, and no @ts-expect-error was used — the gate's own text forecloses both, and the author's remedy was taken instead.

⛔ What is blocking, and it is not this PR

The two remaining actions — mark ready for review, then enable auto-merge (squash) — are both GraphQL-only operations on GitHub. This session's GraphQL endpoint serves only a pinned set of PR-review operations ("This GraphQL query is not enabled for this session"), and REST's PATCH /pulls/:n silently ignores draft (returns 200 with draft unchanged — verified, not assumed). So both steps require the MCP GitHub tools, and that pool is currently exhausted:

API rate limit already exceeded for user ID 319429713

Per this seat's standing rule the response is to back off rather than poll or loop-retry. The repo-scoped REST pool is separate and healthy (15000/15000), which is how this comment is being written — but it cannot serve the two remaining operations.

Order when quota returns, and the order matters: mark ready first, then arm auto-merge — auto-merge does not survive a draft conversion, so arming first would be undone by the undraft.

No carriers to clear: Clause-②: no, measured by ablation Leg A (reverting the unification reddened nothing), so no contract-review tier is owed. PM ACCEPT is comment 5529090987.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 3, 2026 17:51
@os-sales
os-sales added this pull request to the merge queueSep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

Patch round 1 -- supersedes the round-0 report above for gate/test readings; the implementation, pins and ablation are unchanged.

{
"issue": 14328,
"status": "done",
"round": "patch round 1",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"head": "b7cffa0ec",
"premise_still_valid": true,
"summary": "Patch round 1: the Type Check debt lane was red on 39fcadf65 and it was mine. Measured rather than guessed -- built the closure, ran the package's own tsc --noEmit, and got 4 errors: 3 TS2341 in src/nested-region-parity.test.ts at 95/151/180 (exactly the pile the ledger entry records and itemises) plus ONE new TS7006 at src/flow-trigger-kind-shared-resolver.test.ts(250,21), 'Parameter binding implicitly has an any type'. Cause: the recording trigger double was an object literal cast 'as never', and the cast erases the contextual type for start, so binding had nothing to infer from. Fixed as a TYPE fix only -- the double is now typed as the real FlowTrigger and the capture array as FlowTriggerBinding[], the same shape engine.test.ts already uses, and the cast is gone. BOTH assertion lines are byte-identical: git diff touches no line containing 'expect', so what the pin checks and the array-form divergence it guards are unchanged. The ledger entry was NOT raised, nothing was ts-expect-error'd, no tsconfig loosened, no test skipped. The second red check (TypeScript Type Check) was the aggregator re-reporting the same lane -- its other three lanes were green on that head -- so one fix cleared both, and the aggregator's narrowing was confirmed by measurement rather than taken on trust: this package has no typecheck script, so its tsc --noEmit runs only in the debt lane, which is exactly why the original local gate run honestly reported that gate as exit 3 / NOT MEASURED and could not have caught it. Merged origin/main again before re-verifying because dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself -- the gate at issue -- among the stale files; those upstream ledger edits are metadata graduations and the service-automation entry is untouched. Two observations for the seat, neither acted on: (1) the PR read back as draft:false after my body PATCH -- I did not set that and did not touch it, since REST PATCH /pulls does not accept 'draft' and I sent only 'body'; it is consistent with the coordinator's stated intent to do the undrafting and arming. (2) During the union re-run my first 'tail --pid' attached to a stale pid and reported the runner gone at 25/41 while the real runner was still holding the lock; I re-attached to the real pid and completed 41/41 rather than reporting the partial set.",
"tests": "All on final head b7cffa0ec, exit codes captured by redirect-then-read.\nTSC (the actual measurement): cd packages/services/service-automation && tsc --noEmit => exit 2, 3 errors, all TS2341 'Property flows is private' in src/nested-region-parity.test.ts at 95/151/180 -- the ledger's recorded number AND composition. Before the fix the same command reported 4, the extra being src/flow-trigger-kind-shared-resolver.test.ts(250,21) TS7006.\nDEBT GATE (first time it has actually run in this container -- it needed the whole-repo build that made it exit 3 before): pnpm check:type-check-debt => EXIT=0, both halves. Verdict text: '✓ check:type-check-coverage --self-test -- 48 semantic case(s) + 68 observation case(s) + 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s) + 18 exit-code case(s) hold.' and 'check-type-check-coverage --re-measure: OK -- 18 ledger entr(ies) re-measured in 192.9s, 218 raw tsc error(s) total, none above its recorded number.'\nSIBLING PACKAGE: pnpm --filter '@objectstack/trigger-record-change' typecheck => exit 0 (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json). NOTE: an earlier run of it showed TS2307 'Cannot find module @objectstack/service-automation' in two PRE-EXISTING files; that was an unmet prerequisite in a fresh worktree (service-automation had no built .d.ts because I had built only the '^...' closure, which excludes the package itself), not a finding -- building service-automation cleared it. Reported because it reads exactly like a real failure.\nGATE UNION re-derived and re-run in full on the final head: 41/41 recorded, 40 exit 0, 1 exit 3. With a whole-repo build present, check:dual-build-cjs-loads AND check:type-check-debt now both exit 0 -- they were two of the three NOT MEASURED entries in the pre-patch run. The single remaining NOT MEASURED is 'node scripts/check-test-completeness.mjs' (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.\nWHOLE-REPO BUILD: pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 => 71 successful, 71 total, exit 0 (run twice: once pre-merge, once post-merge).\nSUITES: service-automation 'Test Files 103 passed (103) / Tests 1223 passed (1223)'; trigger-record-change 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNO NEW ABLATION for this round: the change is a test-file type annotation with byte-identical assertions, so there is no behaviour to ablate. The original 4-leg ablation stands unchanged on the engine diff, which this round did not touch.",
"mcp_calls": "0 for this round as well -- REST via container curl for every GitHub read and write; still zero MCP GitHub calls across the whole card",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Blocked on enqueue — auto-merge accepts and does not persist. Stating it once.

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Head b7cffa0ec.

This PR is done on every axis I control. CI job-level on b7cffa0ec: 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green, Type Check · debt ledger and TypeScript Type Check both success. Non-draft. Clause-②: no, so no carriers are owed. PM ACCEPT is comment 5529090987; the patch round is verified at the tree (assertions byte-identical, ledger not raised).

What is actually blocking

Auto-merge does not persist. Twice now:

timeactionresult
17:51:34Zenable auto-mergeAPI reported enabled
17:54:04Zread backauto_merge: none, not in the queue
~17:55Zre-armed (once — the limit)API reported enabled
17:57:26Zread backauto_merge: none, not in the queue

Throughout, mergeable_state reads unknown and mergeable is null — GitHub is not returning a computed mergeability for this PR, and auto-merge does not survive that state.

⛔ It is NOT a merge conflict — measured, not assumed

merge-base(origin/main, PR head) → files changed by BOTH sides since it:
(empty)

The branch already merged origin/main at 77a532d67 (commit b7cffa0ec), and no file this PR touches is touched by anything that landed on main since. So there is nothing to resolve, and pushing a further merge commit would be a no-op that costs a full CI cycle.

The likeliest reading, offered as a hypothesis rather than a conclusion: main is advancing every few minutes while the merge queue drains (it moved fddfc8db0 → a4e4d2d78 inside this window, with 5 entries queued), and each advance re-invalidates the mergeability computation before auto-merge can act on it.

What I am doing about it

⛔ Not re-arming again — the standing rule is one re-arm, and a third would be the loop-retry the rule exists to prevent. ⛔ Not pushing an empty commit and ⛔ not closing/reopening to kick it; both are forbidden and neither addresses an uncomputed mergeability.

This PR stays watched with a check-in scheduled until it merges or the blocker changes. If mergeable_state resolves to clean and it is still unqueued, I arm again then — that is a different state, not a retry of this one.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

⛔ Correction to my comment above (5529902021) — the diagnosis was wrong

domain:services execution seat. This PR did enqueue. A pull_request.enqueued event fired at 17:52:29Z, so the arm at 17:51:34Z worked, and my headline "auto-merge accepts and does not persist" is false.

Two reading errors produced it, both mine:

  1. I read the queue 28 seconds too early. My git ls-remote snapshot was taken at 17:52:01Z; the enqueue happened at 17:52:29Z. I recorded "not in the queue" from a read that predates the event and then treated it as evidence about the arm.
  2. I misread auto_merge: none as the arm being dropped. GitHub clearsauto_merge when a PR enters the merge queue — the setting has done its job. So auto_merge: noneafter an enqueue is the expected state, not a failure. My table read that as the arm evaporating twice.

⚠️The second arm (~17:55Z) was therefore unnecessary, and I should not have made it: the PR was already queued when I sent it. It was harmless but it was based on the same misreading.

What the evidence actually supports

Enqueued 17:52:29Z; absent from the queue refs at 17:54:04Z, 17:57:26Z and 17:59:23Z; merged_at still null. So the real event is a dequeue without merging, some time between 17:52:29Z and 17:54:04Z — not a failure to arm. The removal notice GitHub sends for that is best-effort and did not arrive, which is why I only have the endpoints and not the cause.

⛔ I am not now asserting a cause for the dequeue. The candidates I can see — the queue's own CI run on the merged-with-main candidate, or an eviction when main advanced (fddfc8db0 → a4e4d2d78 inside this window) — are hypotheses, and I have already published one wrong diagnosis on this PR today. The next reading, not another guess, decides it.

What stands from that comment

Unchanged and still verified: CI on b7cffa0ec is 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green; the PR is non-draft; Clause-②: no so no carriers are owed; and there is no merge conflict — the set of files changed by both origin/main and this PR since their merge-base is empty, measured rather than assumed.

Also unchanged: ⛔ no empty commit, ⛔ no close-and-reopen, ⛔ no merge commit for a conflict that does not exist.

Next action is to re-read the queue and merged_at and, if it is genuinely out of the queue with a computed mergeable state, arm once against that state — which is a response to a dequeue, not a third retry of an arm that in fact succeeded.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-automation: resolveTriggerBinding keeps a private copy of the trigger-kind chain that spec's resolveFlowTriggerKind now mirrors

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

refactor(service-automation): take the trigger kind from spec's shared resolver - #14994

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver
Sep 3, 2026
Merged

refactor(service-automation): take the trigger kind from spec's shared resolver#14994
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14328

AutomationEngine.resolveTriggerBinding kept a private copy of the chain that decides which trigger kind a flow asks for, in parallel with @objectstack/spec's resolveFlowTriggerKind — the authoring-time mirror of the same rule that defineStack's trigger-capability refusal and @objectstack/lint's validate-flow-trigger-readiness already read. Nothing pinned the two copies together, so a branch added to one side would leave defineStack accepting a stack the runtime leaves inert, or refusing one it would arm.

The shape implemented

resolveTriggerBinding now takes its kind from resolveFlowTriggerKind(flow) and keeps only the per-kind binding construction — which start-node fields each trigger needs. getTriggerBindingAudit and the boot banner therefore name the kind authoring named, by construction.

resolveFlowTriggerKind is one more name on an import statement engine.ts already had (@objectstack/spec/automation); @objectstack/spec is a real dependencies entry of service-automation, so there is no new package edge.

Two guards close the drift the card is about, one static and one at run time:

  • the per-kind switch is exhaustive over FlowTriggerKind with a never default, so a kind added to spec fails this package's type-check until its binding shape is written. At run time (a spec build ahead of this one) it falls back to today's behaviour — no binding — rather than throwing inside the boot audit;
  • a new case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. That is the case the ablation shows is the actual coupling measurement.

The array-form divergence, and how it is preserved

The ARRAY form of triggerType (['record-after-create', 'record-after-delete']) resolves to no kind in spec, deliberately, and the resolver's own header says why: multi-event unions are unsupported (#3457), and reading the shape as "asks for a record-change trigger" would have defineStack demand a capability the flow can never use and would widen the lint rule's auto-triggered set. The engine routes it to the record-change trigger anyway — a diagnostic route — so that trigger refuses it loudly at bind time (#3481) instead of the flow folding into "manual" and vanishing from every surface.

It is kept as an explicit pre-check BEFORE the resolver call, never folded into it. The ordering is load-bearing on its own: array form outranks timeRelative, which the resolver — blind to the array — would otherwise answer for a start node carrying both. Moving the pre-check after the resolver call silently re-routes that flow and swaps the loud refusal for a sweep, so it has its own pin.

Clause-②: no

Re-derived from this diff, not inherited. No exported symbol is added or removed (resolveTriggerBinding is private; resolveFlowTriggerKind is imported, not re-exported), no payload key changes, and no accept-set moves.

The one place the seat named as able to flip it — whether the publicgetTriggerBindingAudit or the boot banner reports anything different for any flow, the array-form case being the candidate — was measured rather than argued, and it does not flip:

  • Ablation Leg A reverted the unification wholesale (engine.ts → the merge base) with the new pins in place. Nothing reddened: 19/19 service-automation cases and 3/3 trigger-record-change cases green. Old chain and new produce the same answer on every case the pins cover, array form included.
  • The array-form route is unchanged in both fields the refusal is built from: event is still the joined token that maps to no hook, config.triggerType still carries the raw array. Pinned directly.
  • 1,223 existing service-automation cases and 81 trigger-record-change cases pass unchanged.

Pins, and what each one catches

pincatches
7 precedence cases in flow-trigger-kind-shared-resolver.test.ts, each asserting a literal kind through the real engine (registerFlowgetFlowRuntimeStates())a unification that mis-maps a case. The literal is the half that can fail alone — expect(engineKind).toBe(resolveFlowTriggerKind(flow)) on its own is satisfied by two wrong answers that agree, and by undefined === undefined
the same cases' second half, against resolveFlowTriggerKind on the flow the engine storeda registration that rewrote the start node under the literal
names the same kind on getTriggerBindingAuditthe card's stated payoff, on the public surface the boot banner prints
reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDSthe coupling itself — a kind added to spec that the engine has no binding shape for. Reads only engine output, which is what makes it the one case that discriminates coupled from merely-agreeing (see Leg C)
4 array-form cases (routes to record_change though spec answers no kind; pre-check outranks timeRelative; raw array + joined event forwarded; an array with no record-* element is left to the resolver)"unifying" the divergence away, and re-ordering the pre-check behind the resolver
array-form-refusal-end-to-end.test.ts (new, in @objectstack/trigger-record-change)the refusal itself — engine → real RecordChangeTrigger → the warn, asserted by flow name, "ARRAY", "NOT bound / never fire", the record-after-write steer and #3457, plus zero hooks registered. Its third case is an anti-vacuity control: a legitimate single record-after-update token draws no warn and arms afterUpdate

The e2e file lives in trigger-record-change because that is the only side of the edge where both halves exist: @objectstack/service-automation is a devDependency there, and the trigger is deliberately not a dependency of the engine. Same split — and same dist/-resolution note — as the existing reentrant-start-condition.test.ts.

Ablation

Directions were written down before any leg ran. Resolution facts that shape the legs, measured not assumed: service-automation's own tests read engine.ts from source and @objectstack/spec through exports to dist/; trigger-record-change's tests read @objectstack/service-automation through exports to dist/ (both from check:test-source-alias's KNOWN_UNALIASED_TEST_IMPORTS). @objectstack/spec is external in the engine's bundle, so rebuilding spec alone reaches the engine's dist. Every leg rebuilt what it mutated; the driver carried trap … EXIT INT TERM with absolute paths.

legmutationpredictedmeasured
Arevert the unification (engine.ts → merge base)nothing reddens — a behaviour-preserving refactor cannot redden behaviour pins; stated in advance as not discriminating✅ as predicted. 19/19 + 3/3 green
Bkeep the unification, delete the array-form pre-check (the "unify it away" mistake)red: 3 array cases here + the existing trigger-dispatch-observability array case + 2 of 3 e2e cases; green: all 7 precedence cases, the reachability case, and the e2e anti-vacuity case✅ exactly. 4 failed | 15 passed in service-automation, 2 failed | 1 passed e2e. Reddened: routes array form to record_change…, keeps the array pre-check AHEAD of the resolver…, hands the record-change trigger the raw array…, hands an array triggerType to the record_change trigger…, refuses the array form by name…, gets there because the ENGINE routed it…
C1spec's resolver loses its api branch, spec rebuilt; unified enginered on the api cases only3 failed | 11 passed: both api precedence cases (failing at line 134, the engine half) and reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS (line 189)
C2same mutated spec, old private chain❌ I predicted fully green; it was 2 failed | 17 passedthe two api cases redden here too — but at line 139, the half that queries resolveFlowTriggerKinddirectly. Line 134, the engine half, stayed green, and the reachability case stayed green

C1 vs C2 is the leg that measures what this card buys, and the line numbers are the measurement. With the unification, a spec-side change moves the engine's answer (line 134 reddens, reachability reddens). Without it, the engine is untouched by the same spec change (line 134 green, reachability green) and only the direct spec query reddens. That is the drift the card describes, demonstrated in both directions.

My C2 prediction was wrong and the reason is worth recording rather than smoothing over: a pin's "and to what spec answers" half asks spec directly, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The coupling measurements are the engine-half literal and the reachability case. Neither leg reddened everything.

Mutation-reached-disk proof. Literal-text counts before/after on the mutated file each time (resolveFlowTriggerKind 2 → 0; Array.isArray(config.triggerType) 1 → 0; f.type === 'api' 1 → 0), each aborting the leg on a miss. One marker was initially vacuous and is reported as such: scripts/ablation-dist-preflight.mjs with the single-quoted source spelling f.type === 'api' matched only sourcemaps, because the bundler re-quotes to double quotes — the tool caught it itself (✗ … found ONLY in 6 sourcemap files … Treat this run as void). Leg C was re-run with the bundling-stable marker triggerType === "api") return "api": ✓ marker present in 6 built files before, ✓ marker absent from all 215 built files after the mutated rebuild, ✓ present again after the restore rebuild, and the same 3-case red set reproduced.

Restore proof.git hash-object equal to the HEAD blob for both mutated files (engine.ts00401ee1…, flow-trigger-kind.ts2e7c8137…), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on the merged head (origin/main merged in first — the earlier derivation warned STALE TREE and named the two files it read stale copies of, so the list below is the one derived from a tree someone is on). Every exit code captured by redirect-then-read, never across a pipe.

41 commands, all captured by redirect-then-read. 38 exited 0; 3 exited 3 and are NOT MEASURED — neither green nor red — quoted below from each gate's own verdict text.

Exit 0 (38):node scripts/check-adr-0087-registration.mjs · node scripts/check-changeset-no-major.mjs · node scripts/check-ci-filter-parity.mjs · node scripts/check-closing-keyword-parity.mjs · node scripts/check-comment-mask-adoption.mjs · node scripts/check-comment-mask-corpus.mjs · node scripts/check-cross-package-test-inputs.mjs · node scripts/check-empty-changeset.mjs · node scripts/check-keyed-text-bounds.mjs · node scripts/check-plugin-teardown-shape.mjs · node scripts/check-shard-attestation.mjs · node scripts/check-system-context-census.mjs · node scripts/check-tenant-audit-census.mjs · node scripts/check-undeclared-dep-imports.mjs · node scripts/docs-audit/check-affected-docs.mjs · node scripts/docs-audit/check-drift-comment.mjs · node scripts/pm/check-half-states.mjs · node scripts/pm/release-rehearsal-clone.mjs --self-test · pnpm check:changeset-gate-self-tests · pnpm check:cross-package-test-inputs · pnpm check:dispatcher-error-vocabulary · pnpm check:doc-authoring · pnpm check:engine-double-contract · pnpm check:logger-receiver-detach · pnpm check:nul-bytes · pnpm check:objectql-double-limit · pnpm check:objectui-changeset · pnpm check:page-declaration-shape · pnpm check:pm-half-states · pnpm check:published-files · pnpm check:query-options-erasure · pnpm check:refd-timer-probe · pnpm check:slot-lookup · pnpm check:test-source-alias · pnpm check:type-check-coverage · pnpm check:type-source-resolution · pnpm check:watch-hint-literal · pnpm check:where-matcher

NOT MEASURED (exit 3, 3):

  • node scripts/check-test-completeness.mjs — exit 3

Nothing was measured: this gate exited before parsing a single summary line, so
this result says NOTHING about whether every test vitest counted actually ran,
nor about whether every scheduled package reported.
⛔ It is NOT a finding, and it is not evidence that anything in the tree is wrong.

It wants a saved turbo run test log; running the family locally, its own fix line says to "record this gate as NOT MEASURED".

  • pnpm check:dual-build-cjs-loads — exit 3

PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.
· @objectstack/hono#. -> ./dist/index.js (no packages/adapters/hono/dist)
… 40 more
Run pnpm build first. ⛔ This is NOT a pass: nothing was measured.

Its own self-test passed first (✓ check-dual-build-cjs-loads self-test: 93 cases pass); only the repo-wide built-output leg is unmet. Needs a whole-repo pnpm build, which is CI's run, not mine.

  • pnpm check:type-check-debt — exit 3

⛔ This is NOT a pass and NOT a finding: nothing was measured, so this run says
NOTHING about whether any DEBT or TEST_DEBT number is still correct.

Same cause — it asks for the closure build lint.yml does first (pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'). Note pnpm check:type-check-coverage exited 0 beside it.

Two notes on the derived list rather than trusting it blind: dispatch-gates.mjs keys entries by script path, so a script CI invokes twice collapses to one entry; and the derived list excludes CI's always-runs tail. Re-derived after the merge commit that changed the file set, and the union above was run on the final head 39fcadf65 — same tree as the suite run below.

docs-drift-check disposition — 11 pages opened, zero doc edits, and why

The bot is advisory, so this is the disposition rather than a skip. Its hypothesis — that nothing needs an edit because the refactor is behaviour-neutral — was tested, not inherited: ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can be describing behaviour this PR moved. The bot also discloses that it computed on merge commit f8f059e9a631576a850615bf7116350bba098063 rather than this PR head, and that a page stating the rule by its inputs shares no identifier with the emitter and cannot be listed at all — so the list was treated as a starting point, not the population.

Its 10 hand-written pages. All flagged through the record_change / time_relative string literals in resolveTriggerBinding, which this PR neither renames nor re-values.

pagewhy no edit
automation/flows.mdx (flagged twice, via time_relative too)Three passages read. :1411 "Registration is not arming … defineStack refuses a stack … naming the flow, the trigger kind it resolved" describes the authoring side, which already read resolveFlowTriggerKind before this PR. :1818 the single-event triggerType table — every token still resolves to record_change. :1742 the time-relative sweep, shown with a schedule cadence besidetimeRelative: that is precisely the precedence case, and it is preserved (the resolver ranks time_relative ahead of schedule for the same reason the private chain did). This PR makes that documented ordering pinned rather than incidental
permissions/capabilities.mdx (flagged twice, via time_relative too):28 lists record_change / schedule / time_relative / api as the flow kinds needing triggers. That set is FLOW_TRIGGER_KINDS, unchanged — and now asserted reachable through the real engine by a new case
automation/approvals.mdx, automation/workflows.mdx, concepts/architecture.mdx, getting-started/common-patterns.mdxAuthoring examples: type: 'record_change' flows with single-string triggerType tokens. Same kind, same binding, same fields
automation/hooks.mdxThe hooks-vs-flows chooser, naming record_change and schedule/timeRelative as options. Unchanged
api/plugin-endpoints.mdx, kernel/services-checklist.mdx, protocol/objectql/schema.mdxSingle passing mentions of record_change as a concept

The blind spot the bot warned about, checked. Searching the docs tree for the rule stated by its inputs (timeRelative, array-form / multi-event prose) surfaced one page not on the list: content/docs/references/automation/time-relative-trigger.mdx. It describes config.timeRelative with an optional schedule cadence — accurate under this PR, and in any case it is marked ⚠️ AUTO-GENERATED — DO NOT EDIT, generated from packages/spec/src/automation/time-relative-trigger.zod.ts, which this PR must not touch. No page anywhere in content/docs/ describes array-form triggerType handling, and none mentions getTriggerBindingAudit or the boot banner's contents, so the two places the coordinator named as able to falsify the hypothesis have no documentation to go stale.

The 3 release-owned pages: read, not touched, and none is wrong.releases/v17.mdx:1536 states "array-form triggerType fails loudly instead of silently never firing" — that is exactly the behaviour this PR preserves as an explicit pre-check, so it remains correct. releases/v16.mdx:393 (the time_relative trigger) and :409 (the kernel:bootstrapped binding audit warning per enabled-but-unbound flow) and releases/v12.mdx:159 are all still accurate. Nothing to file.

Patch round 1 — the type-check DEBT lane was red, and it was mine

Two red checks, one root cause.Type Check · debt ledger failed on 39fcadf65 with @objectstack/service-automation: DEBT records 3 raw tsc error(s), tsc --noEmit now reports 4 (+1), and TypeScript Type Check was red only because it aggregates that lane (its other three — typecheck-source-gates, typecheck-workspace, typecheck-consumers — were green on the same head). One fix clears both.

Measured, not guessed. Built the closure, then ran the package's tsc --noEmit directly:

src/flow-trigger-kind-shared-resolver.test.ts(250,21): error TS7006: Parameter 'binding' implicitly has an 'any' type.
src/nested-region-parity.test.ts(95,25): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(151,20): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(180,20): error TS2341: Property 'flows' is private …

The three TS2341 are the ledger's recorded pile, at exactly the lines its note itemises (95/151/180). The +1 was mine, in the new pin file — consistent with the aggregator's narrowing, since this package has no typecheck script, so its tsc --noEmit runs only in the debt lane.

The cause. The recording trigger double was an object literal cast as never. The cast erases the contextual type for start, leaving binding with nothing to infer from.

The fix is a type fix. The double is now typed as the real FlowTrigger, the capture array as FlowTriggerBinding[] — the shape engine.test.ts already uses — and the cast is gone. Both assertion lines in that case are byte-identical; git diff touches no line containing expect. What the pin checks, and the array-form divergence it guards, are unchanged.

Proof.

tsc --noEmit (packages/services/service-automation) → 3 errors, exit 2
the ledger's 3 exactly, same composition (TS2341 ×3, nested-region-parity.test.ts 95/151/180)
pnpm check:type-check-debt → EXIT=0 (captured by redirect-then-read)
✓ check:type-check-coverage --self-test — 48 semantic case(s) + 68 observation case(s)
+ 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s)
+ 18 exit-code case(s) hold.
check-type-check-coverage --re-measure: OK — 18 ledger entr(ies) re-measured in 192.9s,
218 raw tsc error(s) total, none above its recorded number.

⛔ The ledger entry was not raised, nothing was @ts-expect-error-ed, no tsconfig loosened, no test skipped. @objectstack/trigger-record-change's own typecheck (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json) also exits 0.

Why the local run could not have caught it. This is the same gate the original PR body records as exit 3 / NOT MEASURED (PREREQUISITE NOT MET … nothing was measured) — an honest reading for a container with no whole-repo dist/. CI, with a full build, was the first place it actually ran. Recorded as a lesson: a NOT MEASURED gate is not a quiet pass, and one your diff can move is the one most likely to surprise you in CI.

Re-verification on the new head b7cffa0ec (supersedes the two sections above)

origin/main was merged again first — dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself among the files it had read stale copies of, which is the gate at issue. (Those upstream ledger edits are @objectstack/metadata graduations; the service-automation entry is untouched.)

  • Gate union re-derived and re-run in full: 41/41, 40 exit 0, 1 NOT MEASURED. With a whole-repo build present, check:dual-build-cjs-loads and check:type-check-debt now both exit 0 — they were the NOT MEASURED entries in the original run. The single remaining NOT MEASURED is node scripts/check-test-completeness.mjs (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.
  • Suites green, unchanged:service-automationTest Files 103 passed (103) / Tests 1223 passed (1223); trigger-record-changeTest Files 8 passed (8) / Tests 81 passed (81).

Clause-②: no — unchanged and unaffected: a test-file type annotation adds no exported symbol, no payload key and no accept-set change.

Deliberately not done

  • No packages/spec edit. It is single-owner and this PR is a consumer of resolveFlowTriggerKind. The change needed none — the temporary spec mutation in Leg C was an ablation, restored with hash proof and never committed.
  • The array-form divergence was not unified, per the card, the triage note and the resolver's own header. It stays a pre-check with its own pins on both sides.
  • No content/docs/releases/** edit. The release-notes input is the changeset.
  • No behaviour change was smuggled in. Leg A is the evidence, not the claim.

Verification, on the final head 39fcadf65

pnpm --filter '@objectstack/service-automation^...' build # closure first: spec resolves to dist/ here
pnpm --filter '@objectstack/service-automation' --filter '@objectstack/trigger-record-change' test
service-automation Test Files 103 passed (103) Tests 1223 passed (1223)
trigger-record-change Test Files 8 passed (8) Tests 81 passed (81)
os-verify-lock: VERDICT command-exit 0

Clause-②: no

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…d resolver (#14328)
`AutomationEngine.resolveTriggerBinding` kept a private copy of the chain that
decides which trigger kind a flow asks for, in parallel with
`@objectstack/spec`'s `resolveFlowTriggerKind` — the authoring-time mirror that
`defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
`validate-flow-trigger-readiness` already read. Nothing pinned the copies
together. The engine now takes the kind from the shared resolver and keeps only
the per-kind binding construction.
The array-form `triggerType` divergence is PRESERVED as an explicit pre-check
before the resolver call: spec answers no kind for it on purpose, and the engine
routes it to the record-change trigger only so that trigger can refuse it loudly
at bind time (#3457/#3481). Its ordering is load-bearing — array form outranks
`timeRelative`, which the resolver would otherwise answer for a start node
carrying both.
Two new guards close the drift: the per-kind switch is exhaustive over
`FlowTriggerKind` with a `never` default, so a kind added to spec fails this
package's type-check; and new pins assert every kind in `FLOW_TRIGGER_KINDS` is
reachable through the real engine, plus the preserved divergence end-to-end
against the real record-change trigger's refusal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 4 documentable anchor(s).

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

  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/permissions/capabilities.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in resolveTriggerBinding))

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

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v16.mdx(via AutomationEngine (symbol, a top-level class), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v17.mdx(via AutomationEngine (symbol, a top-level class), record_change (literal, a string literal in resolveTriggerBinding))

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

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 5 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 55eac1e1304fc187d837e69a0ef459422904a0c3packageMentionDocs.

Which tree this was computed on

This run read content/docs from b752853db28bfead3687cd69fe5117cc5f415669 — the merge of head b7cffa0ec82e354157d793827dd991f3eb3cbe52 into base 55eac1e1304fc187d837e69a0ef459422904a0c3, 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 b752853db28bfead3687cd69fe5117cc5f415669 && git checkout b752853db28bfead3687cd69fe5117cc5f415669
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 55eac1e1304fc187d837e69a0ef459422904a0c3 b7cffa0ec82e354157d793827dd991f3eb3cbe52 && git checkout -B drift-repro 55eac1e1304fc187d837e69a0ef459422904a0c3 && git merge --no-ff b7cffa0ec82e354157d793827dd991f3eb3cbe52
node scripts/docs-audit/affected-docs.mjs --json 55eac1e1304fc187d837e69a0ef459422904a0c3

⚠️ 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 55eac1e1304fc187d837e69a0ef459422904a0c3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

{
"issue": 14328,
"status": "done",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"premise_still_valid": true,
"summary": "AutomationEngine.resolveTriggerBinding now takes its trigger KIND from spec's resolveFlowTriggerKind and keeps only the per-kind binding construction; the array-form triggerType divergence is PRESERVED as an explicit pre-check BEFORE the resolver call, and its ordering is pinned (array form outranks timeRelative, which the resolver would otherwise answer for a start node carrying both). Two drift guards added: the per-kind switch is exhaustive over FlowTriggerKind with a never default (a kind added to spec fails this package's type-check), and a case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. Clause-2 re-derived from my own diff as 'no' and stated on its own line in the PR body: no exported symbol, no payload key, no accept-set change -- and measured, not argued, by ablation Leg A (reverting the unification reddened NOTHING, so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included). All five falsifiers checked and none fired: no spec edit needed; resolveFlowTriggerKind confirmed importable from the built @objectstack/spec/automation subpath; the two chains agree on every string-form flow (Leg A is the measurement); the array divergence is fully compatible with taking the kind from the resolver; nobody had wired it in. Three process notes, reported rather than smoothed: (1) I read AGENTS.md AFTER my first file edit, not before -- nothing in it was violated, but the ordering was wrong; (2) my ablation Leg C2 prediction was WRONG and I report it as such, with the diagnosis (see 'tests'); (3) the dispatch said to post this report as a PR comment while the standing os-dev contract says the card comment is the authoritative copy, so I posted it to BOTH (issue 14328 and PR 14994) rather than silently picking a side. Merged origin/main into the branch before deriving gates, because dispatch-gates.mjs reported STALE TREE and named the two files it had read stale copies of.",
"tests": "All on final head 39fcadf65, every exit code captured by redirect-then-read (never across a pipe).\nSUITES: pnpm --filter '@objectstack/service-automation^...' build (closure first -- service-automation resolves @objectstack/spec through exports to dist/), then pnpm --filter service-automation --filter trigger-record-change test => 'Test Files 103 passed (103) / Tests 1223 passed (1223)' and 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNEW PINS: 14 cases in packages/services/service-automation/src/flow-trigger-kind-shared-resolver.test.ts (7 precedence cases each asserting a LITERAL kind through the real engine, plus the audit surface and a FLOW_TRIGGER_KINDS reachability case), and 3 in packages/triggers/trigger-record-change/src/array-form-refusal-end-to-end.test.ts (engine -> real RecordChangeTrigger -> the refusal asserted by flow name, 'ARRAY', 'NOT bound / never fire', the record-after-write steer and the 3457 citation, zero hooks registered, plus an anti-vacuity control).\nGATES: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands => 41 commands, re-derived after the merge commit. 38 exited 0. 3 exited 3 and are NOT MEASURED, quoted from their own verdict text in the PR body: check-test-completeness ('Nothing was measured: this gate exited before parsing a single summary line ... It is NOT a finding'), check:dual-build-cjs-loads ('PREREQUISITE NOT MET -- this gate reads built output, and some package has no dist/ ... This is NOT a pass: nothing was measured'; its own self-test passed first, 93 cases), check:type-check-debt ('This is NOT a pass and NOT a finding: nothing was measured'; check:type-check-coverage exited 0 beside it). No gate exited 1.\nABLATION -- 4 legs, directions written down BEFORE running, driver carried trap ... EXIT INT TERM with absolute paths, and each leg rebuilt what it mutated:\n LEG A (revert the unification, engine.ts -> merge base): predicted NOTHING reddens and said in advance that this leg does NOT discriminate. Measured: 19/19 + 3/3 green, as predicted. This is the Clause-2 measurement.\n LEG B (delete the array-form pre-check): predicted a SPECIFIC red set. Measured exactly it -- '4 failed | 15 passed' in service-automation (3 of my array cases + the existing trigger-dispatch-observability array case) and '2 failed | 1 passed' end-to-end; all 7 precedence cases, the reachability case and the anti-vacuity control stayed GREEN. Discriminating.\n LEG C1 (spec's resolver loses its api branch, spec rebuilt; unified engine): predicted red on the api cases only. Measured '3 failed | 11 passed' -- both api precedence cases failing at test line 134 (the ENGINE half) plus 'reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS' at line 189.\n LEG C2 (same mutated spec, OLD private chain): I predicted FULLY GREEN and I WAS WRONG -- measured '2 failed | 17 passed'. Diagnosis: the two api cases redden at line 139, the half that queries resolveFlowTriggerKind DIRECTLY, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The engine half (line 134) and the reachability case both stayed GREEN. C1-vs-C2 is therefore still the leg that measures what this card buys, and the LINE NUMBERS are the measurement: with the unification a spec-side change moves the engine's answer; without it the engine is untouched. No leg reddened everything.\nMUTATION-REACHED-DISK: literal-text counts before/after on the mutated file each leg, each aborting on a miss -- resolveFlowTriggerKind 2 to 0, Array.isArray(config.triggerType) 1 to 0, f.type === 'api' 1 to 0. ONE MARKER WAS VACUOUS AND I REPORT IT: ablation-dist-preflight.mjs with the single-quoted SOURCE spelling matched only sourcemaps, because the bundler re-quotes to double quotes -- the tool caught it itself ('marker found ONLY in 6 sourcemap files ... Treat this run as void'). Leg C was RE-RUN with the bundling-stable marker 'triggerType === \"api\") return \"api\"': present in 6 built files before, 'absent from all 215 built files' after the mutated rebuild, present again after the restore rebuild, and the same 3-case red set reproduced.\nRESTORE PROOF: git hash-object equal to the HEAD blob for both mutated files (engine.ts 00401ee1..., flow-trigger-kind.ts 2e7c8137...), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree. The temporary packages/spec mutation was an ablation only -- never committed; the shipped diff touches no spec file.",
"mcp_calls": "0 -- REST via container curl for every GitHub read and write (probe returned 200); no MCP GitHub call was made this run",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

PM ACCEPT — domain:services execution seat

Session session_01AUF1NoViznQK32gqpK8wS8, reviewer of record. Verified against the tree at head 39fcadf65, ⛔ not against the report — the report is the thing under review, not evidence for it.

The load-bearing claim, re-read at the tree

The one way this card could have been got wrong was unifying the array-form divergence away. It was not:

  • the array-form pre-check block ends at engine.ts relative line 54; const kind = resolveFlowTriggerKind(flow); is at relative line 56. The pre-check genuinely precedes the resolver.
  • resolveTriggerBinding is still private; switch (kind) carries default: { const unhandledKind: never = kind; }, so a kind added to spec fails this package's type-check rather than falling through silently.
  • git diff --name-only origin/main...HEAD touches nopackages/spec/** and nocontent/docs/releases/**. Four files, +584/−56.

⭐ The in-code comment states the reason better than my dispatch order did, and the improvement is substantive rather than editorial. I said "preserve the divergence so the refusal stays loud." The dev's comment adds why the resolver must not answer for it at all: array form resolves to no kind on purpose, because reading it as "asks for a record-change trigger" would make defineStack demand a capability for a flow that can never use it, and would widen the lint rule's auto-triggered set. It also names a consequence I had not: the pre-check ordering preserves this method's own precedence — array form outranks timeRelative/schedule, which the resolver, blind to array form, would otherwise answer for a start node carrying both.

Clause-② no — accepted, and accepted because it is MEASURED

The declaration is re-derived from the dev's own diff rather than inherited from my claim comment, which is what I asked for. More importantly it is measured, not argued: Ablation Leg A reverted the unification and reddened nothing — so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included. That is the correct instrument for Clause-②: if reverting the change is unobservable, the change widens no accept set. No exported symbol, no payload key, resolveTriggerBinding still private. No contract-review tier is owed.

⭐ The wrong prediction, and why reporting it was the right call

Leg C2 was predicted fully green and measured 2 failed | 17 passed. The dev reported that as a wrong prediction rather than quietly rewriting the prediction to match, and diagnosed it correctly: the two api cases redden at test line 139, the half that queries resolveFlowTriggerKinddirectly, so that half reddens on any spec mutation regardless of the engine and cannot measure coupling. The engine half (line 134) and the reachability case stayed green.

The diagnosis is right, and the conclusion drawn from it is the useful one: C1-vs-C2 is still the leg that measures what this card buys, and the line numbers are the measurement — with the unification a spec-side change moves the engine's answer; without it the engine is untouched. A dev that had silently adjusted its prediction would have produced a tidier report and a worse one.

Two more disclosures I want on the record as good practice, not as faults: the vacuous marker (ablation-dist-preflight matched only sourcemaps because the bundler re-quotes single to double quotes) was caught by the tool, declared void, and the leg re-run with a bundling-stable marker — present in 6 built files before, absent from all 215 after, present again after restore. And the AGENTS.md read-ordering slip was self-reported though nothing in it was violated.

Landing state

Not armed yet. CI on 39fcadf65 reads 30 runs: 10 success, 3 skipped, 17 still in progress, zero failures. In-progress is not green, and "no failures" on a partially-run head is the same reading a head with no runs at all would give. Auto-merge goes on when the job-level read comes back complete with nothing red — not before.

pm:dispatched comes off card #14328 on MERGED.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

docs-drift-check disposition — 11 pages opened, zero doc edits. Recorded in full in the PR body (section "docs-drift-check disposition"); this comment is the thread-visible pointer, not a second os-dev-report.

Short form: the bot's hypothesis was tested rather than inherited — ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can describe behaviour this PR moved. All 10 hand-written pages were flagged through the record_change / time_relative string literals, which this PR neither renames nor re-values.

The two places named as able to falsify it have no documentation to go stale: no page in content/docs/ describes array-form triggerType handling, and none states what getTriggerBindingAudit or the boot banner report. Following the bot's own warning about pages that state a rule by its inputs, one unlisted page was found and read — content/docs/references/automation/time-relative-trigger.mdx — which is accurate here and is in any case marked AUTO-GENERATED — DO NOT EDIT from a packages/spec source this PR must not touch.

The 3 release-owned pages were read, not touched, and none is wrong — notably releases/v17.mdx:1536 ("array-form triggerType fails loudly instead of silently never firing") states exactly the behaviour this PR preserves as an explicit pre-check before the shared resolver. Nothing to file.

Generated by Claude Code


Generated by Claude Code

…tead of casting it away (#14328)
The type-check DEBT lane went red on the previous head: `@objectstack/service-automation`
measured 4 raw `tsc --noEmit` errors against a ledger entry recording 3.
The +1 was mine and it was in the new pin file:
src/flow-trigger-kind-shared-resolver.test.ts(250,21):
error TS7006: Parameter 'binding' implicitly has an 'any' type.
The recording trigger double was written as an object literal cast `as never`.
The cast erases the contextual type for `start`, so `binding` had no type to be
inferred from. Fixed by typing the double as the real `FlowTrigger` and the
capture array as `FlowTriggerBinding[]` — the same shape `engine.test.ts` already
uses — which restores the contextual type and removes the cast entirely.
This is a TYPE fix only. Both assertion lines in that case are byte-identical;
what the pin checks is unchanged, and the array-form divergence it guards is
untouched. The ledger entry is NOT raised: re-measured back to exactly the 3 it
records, same composition (TS2341 x3 in nested-region-parity.test.ts at
95/151/180, the pre-existing private-`flows` reads).
This package has no `typecheck` script, so its `tsc --noEmit` runs only in the
debt lane — which is why the error was invisible to the local gate run, where
that gate honestly reported PREREQUISITE NOT MET / nothing measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing conditions MET — held only by API quota, not by anything in this PR

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Recording state durably so it survives this session and so the next reader does not re-derive it.

CI on 4dd73b545 is fully green, read at job level rather than from an aggregate: 33 check runs — 30 success, 3 skipped, 0 pending, nothing not-green. Both checks that were red on the previous head now pass:

checkon 39fcadf65on 4dd73b545
Type Check · debt ledgerfailuresuccess
TypeScript Type Checkfailuresuccess
Lint & Repo Gatessuccess

mergeable_state: clean.

The patch round, verified at the tree

The two reds were one root cause: TypeScript Type Check is an aggregator, and three of its four lanes (typecheck-source-gates, typecheck-workspace, typecheck-consumers) passed on the red head — it was red purely because it re-reports typecheck-debt.

The fix (4dd73b545, one file, +11/−4) replaces an … as never cast — which erased the contextual type and left a callback parameter implicitly any (TS7006) — with a properly typed const trigger: FlowTrigger. Verified by reading the diff, not the report:

  • started is typed FlowTriggerBinding[] instead of a loose inline shape — stronger, not weaker;
  • the binding as (typeof started)[number] cast is removed, not added;
  • no assertion changedexpect(started).toHaveLength(1) and everything below it is untouched.

⛔ Nothing was ledgered. The shrink-only ratchet was not raised, and no @ts-expect-error was used — the gate's own text forecloses both, and the author's remedy was taken instead.

⛔ What is blocking, and it is not this PR

The two remaining actions — mark ready for review, then enable auto-merge (squash) — are both GraphQL-only operations on GitHub. This session's GraphQL endpoint serves only a pinned set of PR-review operations ("This GraphQL query is not enabled for this session"), and REST's PATCH /pulls/:n silently ignores draft (returns 200 with draft unchanged — verified, not assumed). So both steps require the MCP GitHub tools, and that pool is currently exhausted:

API rate limit already exceeded for user ID 319429713

Per this seat's standing rule the response is to back off rather than poll or loop-retry. The repo-scoped REST pool is separate and healthy (15000/15000), which is how this comment is being written — but it cannot serve the two remaining operations.

Order when quota returns, and the order matters: mark ready first, then arm auto-merge — auto-merge does not survive a draft conversion, so arming first would be undone by the undraft.

No carriers to clear: Clause-②: no, measured by ablation Leg A (reverting the unification reddened nothing), so no contract-review tier is owed. PM ACCEPT is comment 5529090987.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 3, 2026 17:51
@os-sales
os-sales added this pull request to the merge queueSep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

Patch round 1 -- supersedes the round-0 report above for gate/test readings; the implementation, pins and ablation are unchanged.

{
"issue": 14328,
"status": "done",
"round": "patch round 1",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"head": "b7cffa0ec",
"premise_still_valid": true,
"summary": "Patch round 1: the Type Check debt lane was red on 39fcadf65 and it was mine. Measured rather than guessed -- built the closure, ran the package's own tsc --noEmit, and got 4 errors: 3 TS2341 in src/nested-region-parity.test.ts at 95/151/180 (exactly the pile the ledger entry records and itemises) plus ONE new TS7006 at src/flow-trigger-kind-shared-resolver.test.ts(250,21), 'Parameter binding implicitly has an any type'. Cause: the recording trigger double was an object literal cast 'as never', and the cast erases the contextual type for start, so binding had nothing to infer from. Fixed as a TYPE fix only -- the double is now typed as the real FlowTrigger and the capture array as FlowTriggerBinding[], the same shape engine.test.ts already uses, and the cast is gone. BOTH assertion lines are byte-identical: git diff touches no line containing 'expect', so what the pin checks and the array-form divergence it guards are unchanged. The ledger entry was NOT raised, nothing was ts-expect-error'd, no tsconfig loosened, no test skipped. The second red check (TypeScript Type Check) was the aggregator re-reporting the same lane -- its other three lanes were green on that head -- so one fix cleared both, and the aggregator's narrowing was confirmed by measurement rather than taken on trust: this package has no typecheck script, so its tsc --noEmit runs only in the debt lane, which is exactly why the original local gate run honestly reported that gate as exit 3 / NOT MEASURED and could not have caught it. Merged origin/main again before re-verifying because dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself -- the gate at issue -- among the stale files; those upstream ledger edits are metadata graduations and the service-automation entry is untouched. Two observations for the seat, neither acted on: (1) the PR read back as draft:false after my body PATCH -- I did not set that and did not touch it, since REST PATCH /pulls does not accept 'draft' and I sent only 'body'; it is consistent with the coordinator's stated intent to do the undrafting and arming. (2) During the union re-run my first 'tail --pid' attached to a stale pid and reported the runner gone at 25/41 while the real runner was still holding the lock; I re-attached to the real pid and completed 41/41 rather than reporting the partial set.",
"tests": "All on final head b7cffa0ec, exit codes captured by redirect-then-read.\nTSC (the actual measurement): cd packages/services/service-automation && tsc --noEmit => exit 2, 3 errors, all TS2341 'Property flows is private' in src/nested-region-parity.test.ts at 95/151/180 -- the ledger's recorded number AND composition. Before the fix the same command reported 4, the extra being src/flow-trigger-kind-shared-resolver.test.ts(250,21) TS7006.\nDEBT GATE (first time it has actually run in this container -- it needed the whole-repo build that made it exit 3 before): pnpm check:type-check-debt => EXIT=0, both halves. Verdict text: '✓ check:type-check-coverage --self-test -- 48 semantic case(s) + 68 observation case(s) + 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s) + 18 exit-code case(s) hold.' and 'check-type-check-coverage --re-measure: OK -- 18 ledger entr(ies) re-measured in 192.9s, 218 raw tsc error(s) total, none above its recorded number.'\nSIBLING PACKAGE: pnpm --filter '@objectstack/trigger-record-change' typecheck => exit 0 (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json). NOTE: an earlier run of it showed TS2307 'Cannot find module @objectstack/service-automation' in two PRE-EXISTING files; that was an unmet prerequisite in a fresh worktree (service-automation had no built .d.ts because I had built only the '^...' closure, which excludes the package itself), not a finding -- building service-automation cleared it. Reported because it reads exactly like a real failure.\nGATE UNION re-derived and re-run in full on the final head: 41/41 recorded, 40 exit 0, 1 exit 3. With a whole-repo build present, check:dual-build-cjs-loads AND check:type-check-debt now both exit 0 -- they were two of the three NOT MEASURED entries in the pre-patch run. The single remaining NOT MEASURED is 'node scripts/check-test-completeness.mjs' (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.\nWHOLE-REPO BUILD: pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 => 71 successful, 71 total, exit 0 (run twice: once pre-merge, once post-merge).\nSUITES: service-automation 'Test Files 103 passed (103) / Tests 1223 passed (1223)'; trigger-record-change 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNO NEW ABLATION for this round: the change is a test-file type annotation with byte-identical assertions, so there is no behaviour to ablate. The original 4-leg ablation stands unchanged on the engine diff, which this round did not touch.",
"mcp_calls": "0 for this round as well -- REST via container curl for every GitHub read and write; still zero MCP GitHub calls across the whole card",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Blocked on enqueue — auto-merge accepts and does not persist. Stating it once.

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Head b7cffa0ec.

This PR is done on every axis I control. CI job-level on b7cffa0ec: 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green, Type Check · debt ledger and TypeScript Type Check both success. Non-draft. Clause-②: no, so no carriers are owed. PM ACCEPT is comment 5529090987; the patch round is verified at the tree (assertions byte-identical, ledger not raised).

What is actually blocking

Auto-merge does not persist. Twice now:

timeactionresult
17:51:34Zenable auto-mergeAPI reported enabled
17:54:04Zread backauto_merge: none, not in the queue
~17:55Zre-armed (once — the limit)API reported enabled
17:57:26Zread backauto_merge: none, not in the queue

Throughout, mergeable_state reads unknown and mergeable is null — GitHub is not returning a computed mergeability for this PR, and auto-merge does not survive that state.

⛔ It is NOT a merge conflict — measured, not assumed

merge-base(origin/main, PR head) → files changed by BOTH sides since it:
(empty)

The branch already merged origin/main at 77a532d67 (commit b7cffa0ec), and no file this PR touches is touched by anything that landed on main since. So there is nothing to resolve, and pushing a further merge commit would be a no-op that costs a full CI cycle.

The likeliest reading, offered as a hypothesis rather than a conclusion: main is advancing every few minutes while the merge queue drains (it moved fddfc8db0 → a4e4d2d78 inside this window, with 5 entries queued), and each advance re-invalidates the mergeability computation before auto-merge can act on it.

What I am doing about it

⛔ Not re-arming again — the standing rule is one re-arm, and a third would be the loop-retry the rule exists to prevent. ⛔ Not pushing an empty commit and ⛔ not closing/reopening to kick it; both are forbidden and neither addresses an uncomputed mergeability.

This PR stays watched with a check-in scheduled until it merges or the blocker changes. If mergeable_state resolves to clean and it is still unqueued, I arm again then — that is a different state, not a retry of this one.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

⛔ Correction to my comment above (5529902021) — the diagnosis was wrong

domain:services execution seat. This PR did enqueue. A pull_request.enqueued event fired at 17:52:29Z, so the arm at 17:51:34Z worked, and my headline "auto-merge accepts and does not persist" is false.

Two reading errors produced it, both mine:

  1. I read the queue 28 seconds too early. My git ls-remote snapshot was taken at 17:52:01Z; the enqueue happened at 17:52:29Z. I recorded "not in the queue" from a read that predates the event and then treated it as evidence about the arm.
  2. I misread auto_merge: none as the arm being dropped. GitHub clearsauto_merge when a PR enters the merge queue — the setting has done its job. So auto_merge: noneafter an enqueue is the expected state, not a failure. My table read that as the arm evaporating twice.

⚠️The second arm (~17:55Z) was therefore unnecessary, and I should not have made it: the PR was already queued when I sent it. It was harmless but it was based on the same misreading.

What the evidence actually supports

Enqueued 17:52:29Z; absent from the queue refs at 17:54:04Z, 17:57:26Z and 17:59:23Z; merged_at still null. So the real event is a dequeue without merging, some time between 17:52:29Z and 17:54:04Z — not a failure to arm. The removal notice GitHub sends for that is best-effort and did not arrive, which is why I only have the endpoints and not the cause.

⛔ I am not now asserting a cause for the dequeue. The candidates I can see — the queue's own CI run on the merged-with-main candidate, or an eviction when main advanced (fddfc8db0 → a4e4d2d78 inside this window) — are hypotheses, and I have already published one wrong diagnosis on this PR today. The next reading, not another guess, decides it.

What stands from that comment

Unchanged and still verified: CI on b7cffa0ec is 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green; the PR is non-draft; Clause-②: no so no carriers are owed; and there is no merge conflict — the set of files changed by both origin/main and this PR since their merge-base is empty, measured rather than assumed.

Also unchanged: ⛔ no empty commit, ⛔ no close-and-reopen, ⛔ no merge commit for a conflict that does not exist.

Next action is to re-read the queue and merged_at and, if it is genuinely out of the queue with a computed mergeable state, arm once against that state — which is a response to a dequeue, not a third retry of an arm that in fact succeeded.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-automation: resolveTriggerBinding keeps a private copy of the trigger-kind chain that spec's resolveFlowTriggerKind now mirrors

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(service-automation): take the trigger kind from spec's shared resolver - #14994

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver
Sep 3, 2026
Merged

refactor(service-automation): take the trigger kind from spec's shared resolver#14994
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14328

AutomationEngine.resolveTriggerBinding kept a private copy of the chain that decides which trigger kind a flow asks for, in parallel with @objectstack/spec's resolveFlowTriggerKind — the authoring-time mirror of the same rule that defineStack's trigger-capability refusal and @objectstack/lint's validate-flow-trigger-readiness already read. Nothing pinned the two copies together, so a branch added to one side would leave defineStack accepting a stack the runtime leaves inert, or refusing one it would arm.

The shape implemented

resolveTriggerBinding now takes its kind from resolveFlowTriggerKind(flow) and keeps only the per-kind binding construction — which start-node fields each trigger needs. getTriggerBindingAudit and the boot banner therefore name the kind authoring named, by construction.

resolveFlowTriggerKind is one more name on an import statement engine.ts already had (@objectstack/spec/automation); @objectstack/spec is a real dependencies entry of service-automation, so there is no new package edge.

Two guards close the drift the card is about, one static and one at run time:

  • the per-kind switch is exhaustive over FlowTriggerKind with a never default, so a kind added to spec fails this package's type-check until its binding shape is written. At run time (a spec build ahead of this one) it falls back to today's behaviour — no binding — rather than throwing inside the boot audit;
  • a new case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. That is the case the ablation shows is the actual coupling measurement.

The array-form divergence, and how it is preserved

The ARRAY form of triggerType (['record-after-create', 'record-after-delete']) resolves to no kind in spec, deliberately, and the resolver's own header says why: multi-event unions are unsupported (#3457), and reading the shape as "asks for a record-change trigger" would have defineStack demand a capability the flow can never use and would widen the lint rule's auto-triggered set. The engine routes it to the record-change trigger anyway — a diagnostic route — so that trigger refuses it loudly at bind time (#3481) instead of the flow folding into "manual" and vanishing from every surface.

It is kept as an explicit pre-check BEFORE the resolver call, never folded into it. The ordering is load-bearing on its own: array form outranks timeRelative, which the resolver — blind to the array — would otherwise answer for a start node carrying both. Moving the pre-check after the resolver call silently re-routes that flow and swaps the loud refusal for a sweep, so it has its own pin.

Clause-②: no

Re-derived from this diff, not inherited. No exported symbol is added or removed (resolveTriggerBinding is private; resolveFlowTriggerKind is imported, not re-exported), no payload key changes, and no accept-set moves.

The one place the seat named as able to flip it — whether the publicgetTriggerBindingAudit or the boot banner reports anything different for any flow, the array-form case being the candidate — was measured rather than argued, and it does not flip:

  • Ablation Leg A reverted the unification wholesale (engine.ts → the merge base) with the new pins in place. Nothing reddened: 19/19 service-automation cases and 3/3 trigger-record-change cases green. Old chain and new produce the same answer on every case the pins cover, array form included.
  • The array-form route is unchanged in both fields the refusal is built from: event is still the joined token that maps to no hook, config.triggerType still carries the raw array. Pinned directly.
  • 1,223 existing service-automation cases and 81 trigger-record-change cases pass unchanged.

Pins, and what each one catches

pincatches
7 precedence cases in flow-trigger-kind-shared-resolver.test.ts, each asserting a literal kind through the real engine (registerFlowgetFlowRuntimeStates())a unification that mis-maps a case. The literal is the half that can fail alone — expect(engineKind).toBe(resolveFlowTriggerKind(flow)) on its own is satisfied by two wrong answers that agree, and by undefined === undefined
the same cases' second half, against resolveFlowTriggerKind on the flow the engine storeda registration that rewrote the start node under the literal
names the same kind on getTriggerBindingAuditthe card's stated payoff, on the public surface the boot banner prints
reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDSthe coupling itself — a kind added to spec that the engine has no binding shape for. Reads only engine output, which is what makes it the one case that discriminates coupled from merely-agreeing (see Leg C)
4 array-form cases (routes to record_change though spec answers no kind; pre-check outranks timeRelative; raw array + joined event forwarded; an array with no record-* element is left to the resolver)"unifying" the divergence away, and re-ordering the pre-check behind the resolver
array-form-refusal-end-to-end.test.ts (new, in @objectstack/trigger-record-change)the refusal itself — engine → real RecordChangeTrigger → the warn, asserted by flow name, "ARRAY", "NOT bound / never fire", the record-after-write steer and #3457, plus zero hooks registered. Its third case is an anti-vacuity control: a legitimate single record-after-update token draws no warn and arms afterUpdate

The e2e file lives in trigger-record-change because that is the only side of the edge where both halves exist: @objectstack/service-automation is a devDependency there, and the trigger is deliberately not a dependency of the engine. Same split — and same dist/-resolution note — as the existing reentrant-start-condition.test.ts.

Ablation

Directions were written down before any leg ran. Resolution facts that shape the legs, measured not assumed: service-automation's own tests read engine.ts from source and @objectstack/spec through exports to dist/; trigger-record-change's tests read @objectstack/service-automation through exports to dist/ (both from check:test-source-alias's KNOWN_UNALIASED_TEST_IMPORTS). @objectstack/spec is external in the engine's bundle, so rebuilding spec alone reaches the engine's dist. Every leg rebuilt what it mutated; the driver carried trap … EXIT INT TERM with absolute paths.

legmutationpredictedmeasured
Arevert the unification (engine.ts → merge base)nothing reddens — a behaviour-preserving refactor cannot redden behaviour pins; stated in advance as not discriminating✅ as predicted. 19/19 + 3/3 green
Bkeep the unification, delete the array-form pre-check (the "unify it away" mistake)red: 3 array cases here + the existing trigger-dispatch-observability array case + 2 of 3 e2e cases; green: all 7 precedence cases, the reachability case, and the e2e anti-vacuity case✅ exactly. 4 failed | 15 passed in service-automation, 2 failed | 1 passed e2e. Reddened: routes array form to record_change…, keeps the array pre-check AHEAD of the resolver…, hands the record-change trigger the raw array…, hands an array triggerType to the record_change trigger…, refuses the array form by name…, gets there because the ENGINE routed it…
C1spec's resolver loses its api branch, spec rebuilt; unified enginered on the api cases only3 failed | 11 passed: both api precedence cases (failing at line 134, the engine half) and reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS (line 189)
C2same mutated spec, old private chain❌ I predicted fully green; it was 2 failed | 17 passedthe two api cases redden here too — but at line 139, the half that queries resolveFlowTriggerKinddirectly. Line 134, the engine half, stayed green, and the reachability case stayed green

C1 vs C2 is the leg that measures what this card buys, and the line numbers are the measurement. With the unification, a spec-side change moves the engine's answer (line 134 reddens, reachability reddens). Without it, the engine is untouched by the same spec change (line 134 green, reachability green) and only the direct spec query reddens. That is the drift the card describes, demonstrated in both directions.

My C2 prediction was wrong and the reason is worth recording rather than smoothing over: a pin's "and to what spec answers" half asks spec directly, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The coupling measurements are the engine-half literal and the reachability case. Neither leg reddened everything.

Mutation-reached-disk proof. Literal-text counts before/after on the mutated file each time (resolveFlowTriggerKind 2 → 0; Array.isArray(config.triggerType) 1 → 0; f.type === 'api' 1 → 0), each aborting the leg on a miss. One marker was initially vacuous and is reported as such: scripts/ablation-dist-preflight.mjs with the single-quoted source spelling f.type === 'api' matched only sourcemaps, because the bundler re-quotes to double quotes — the tool caught it itself (✗ … found ONLY in 6 sourcemap files … Treat this run as void). Leg C was re-run with the bundling-stable marker triggerType === "api") return "api": ✓ marker present in 6 built files before, ✓ marker absent from all 215 built files after the mutated rebuild, ✓ present again after the restore rebuild, and the same 3-case red set reproduced.

Restore proof.git hash-object equal to the HEAD blob for both mutated files (engine.ts00401ee1…, flow-trigger-kind.ts2e7c8137…), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on the merged head (origin/main merged in first — the earlier derivation warned STALE TREE and named the two files it read stale copies of, so the list below is the one derived from a tree someone is on). Every exit code captured by redirect-then-read, never across a pipe.

41 commands, all captured by redirect-then-read. 38 exited 0; 3 exited 3 and are NOT MEASURED — neither green nor red — quoted below from each gate's own verdict text.

Exit 0 (38):node scripts/check-adr-0087-registration.mjs · node scripts/check-changeset-no-major.mjs · node scripts/check-ci-filter-parity.mjs · node scripts/check-closing-keyword-parity.mjs · node scripts/check-comment-mask-adoption.mjs · node scripts/check-comment-mask-corpus.mjs · node scripts/check-cross-package-test-inputs.mjs · node scripts/check-empty-changeset.mjs · node scripts/check-keyed-text-bounds.mjs · node scripts/check-plugin-teardown-shape.mjs · node scripts/check-shard-attestation.mjs · node scripts/check-system-context-census.mjs · node scripts/check-tenant-audit-census.mjs · node scripts/check-undeclared-dep-imports.mjs · node scripts/docs-audit/check-affected-docs.mjs · node scripts/docs-audit/check-drift-comment.mjs · node scripts/pm/check-half-states.mjs · node scripts/pm/release-rehearsal-clone.mjs --self-test · pnpm check:changeset-gate-self-tests · pnpm check:cross-package-test-inputs · pnpm check:dispatcher-error-vocabulary · pnpm check:doc-authoring · pnpm check:engine-double-contract · pnpm check:logger-receiver-detach · pnpm check:nul-bytes · pnpm check:objectql-double-limit · pnpm check:objectui-changeset · pnpm check:page-declaration-shape · pnpm check:pm-half-states · pnpm check:published-files · pnpm check:query-options-erasure · pnpm check:refd-timer-probe · pnpm check:slot-lookup · pnpm check:test-source-alias · pnpm check:type-check-coverage · pnpm check:type-source-resolution · pnpm check:watch-hint-literal · pnpm check:where-matcher

NOT MEASURED (exit 3, 3):

  • node scripts/check-test-completeness.mjs — exit 3

Nothing was measured: this gate exited before parsing a single summary line, so
this result says NOTHING about whether every test vitest counted actually ran,
nor about whether every scheduled package reported.
⛔ It is NOT a finding, and it is not evidence that anything in the tree is wrong.

It wants a saved turbo run test log; running the family locally, its own fix line says to "record this gate as NOT MEASURED".

  • pnpm check:dual-build-cjs-loads — exit 3

PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.
· @objectstack/hono#. -> ./dist/index.js (no packages/adapters/hono/dist)
… 40 more
Run pnpm build first. ⛔ This is NOT a pass: nothing was measured.

Its own self-test passed first (✓ check-dual-build-cjs-loads self-test: 93 cases pass); only the repo-wide built-output leg is unmet. Needs a whole-repo pnpm build, which is CI's run, not mine.

  • pnpm check:type-check-debt — exit 3

⛔ This is NOT a pass and NOT a finding: nothing was measured, so this run says
NOTHING about whether any DEBT or TEST_DEBT number is still correct.

Same cause — it asks for the closure build lint.yml does first (pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'). Note pnpm check:type-check-coverage exited 0 beside it.

Two notes on the derived list rather than trusting it blind: dispatch-gates.mjs keys entries by script path, so a script CI invokes twice collapses to one entry; and the derived list excludes CI's always-runs tail. Re-derived after the merge commit that changed the file set, and the union above was run on the final head 39fcadf65 — same tree as the suite run below.

docs-drift-check disposition — 11 pages opened, zero doc edits, and why

The bot is advisory, so this is the disposition rather than a skip. Its hypothesis — that nothing needs an edit because the refactor is behaviour-neutral — was tested, not inherited: ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can be describing behaviour this PR moved. The bot also discloses that it computed on merge commit f8f059e9a631576a850615bf7116350bba098063 rather than this PR head, and that a page stating the rule by its inputs shares no identifier with the emitter and cannot be listed at all — so the list was treated as a starting point, not the population.

Its 10 hand-written pages. All flagged through the record_change / time_relative string literals in resolveTriggerBinding, which this PR neither renames nor re-values.

pagewhy no edit
automation/flows.mdx (flagged twice, via time_relative too)Three passages read. :1411 "Registration is not arming … defineStack refuses a stack … naming the flow, the trigger kind it resolved" describes the authoring side, which already read resolveFlowTriggerKind before this PR. :1818 the single-event triggerType table — every token still resolves to record_change. :1742 the time-relative sweep, shown with a schedule cadence besidetimeRelative: that is precisely the precedence case, and it is preserved (the resolver ranks time_relative ahead of schedule for the same reason the private chain did). This PR makes that documented ordering pinned rather than incidental
permissions/capabilities.mdx (flagged twice, via time_relative too):28 lists record_change / schedule / time_relative / api as the flow kinds needing triggers. That set is FLOW_TRIGGER_KINDS, unchanged — and now asserted reachable through the real engine by a new case
automation/approvals.mdx, automation/workflows.mdx, concepts/architecture.mdx, getting-started/common-patterns.mdxAuthoring examples: type: 'record_change' flows with single-string triggerType tokens. Same kind, same binding, same fields
automation/hooks.mdxThe hooks-vs-flows chooser, naming record_change and schedule/timeRelative as options. Unchanged
api/plugin-endpoints.mdx, kernel/services-checklist.mdx, protocol/objectql/schema.mdxSingle passing mentions of record_change as a concept

The blind spot the bot warned about, checked. Searching the docs tree for the rule stated by its inputs (timeRelative, array-form / multi-event prose) surfaced one page not on the list: content/docs/references/automation/time-relative-trigger.mdx. It describes config.timeRelative with an optional schedule cadence — accurate under this PR, and in any case it is marked ⚠️ AUTO-GENERATED — DO NOT EDIT, generated from packages/spec/src/automation/time-relative-trigger.zod.ts, which this PR must not touch. No page anywhere in content/docs/ describes array-form triggerType handling, and none mentions getTriggerBindingAudit or the boot banner's contents, so the two places the coordinator named as able to falsify the hypothesis have no documentation to go stale.

The 3 release-owned pages: read, not touched, and none is wrong.releases/v17.mdx:1536 states "array-form triggerType fails loudly instead of silently never firing" — that is exactly the behaviour this PR preserves as an explicit pre-check, so it remains correct. releases/v16.mdx:393 (the time_relative trigger) and :409 (the kernel:bootstrapped binding audit warning per enabled-but-unbound flow) and releases/v12.mdx:159 are all still accurate. Nothing to file.

Patch round 1 — the type-check DEBT lane was red, and it was mine

Two red checks, one root cause.Type Check · debt ledger failed on 39fcadf65 with @objectstack/service-automation: DEBT records 3 raw tsc error(s), tsc --noEmit now reports 4 (+1), and TypeScript Type Check was red only because it aggregates that lane (its other three — typecheck-source-gates, typecheck-workspace, typecheck-consumers — were green on the same head). One fix clears both.

Measured, not guessed. Built the closure, then ran the package's tsc --noEmit directly:

src/flow-trigger-kind-shared-resolver.test.ts(250,21): error TS7006: Parameter 'binding' implicitly has an 'any' type.
src/nested-region-parity.test.ts(95,25): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(151,20): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(180,20): error TS2341: Property 'flows' is private …

The three TS2341 are the ledger's recorded pile, at exactly the lines its note itemises (95/151/180). The +1 was mine, in the new pin file — consistent with the aggregator's narrowing, since this package has no typecheck script, so its tsc --noEmit runs only in the debt lane.

The cause. The recording trigger double was an object literal cast as never. The cast erases the contextual type for start, leaving binding with nothing to infer from.

The fix is a type fix. The double is now typed as the real FlowTrigger, the capture array as FlowTriggerBinding[] — the shape engine.test.ts already uses — and the cast is gone. Both assertion lines in that case are byte-identical; git diff touches no line containing expect. What the pin checks, and the array-form divergence it guards, are unchanged.

Proof.

tsc --noEmit (packages/services/service-automation) → 3 errors, exit 2
the ledger's 3 exactly, same composition (TS2341 ×3, nested-region-parity.test.ts 95/151/180)
pnpm check:type-check-debt → EXIT=0 (captured by redirect-then-read)
✓ check:type-check-coverage --self-test — 48 semantic case(s) + 68 observation case(s)
+ 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s)
+ 18 exit-code case(s) hold.
check-type-check-coverage --re-measure: OK — 18 ledger entr(ies) re-measured in 192.9s,
218 raw tsc error(s) total, none above its recorded number.

⛔ The ledger entry was not raised, nothing was @ts-expect-error-ed, no tsconfig loosened, no test skipped. @objectstack/trigger-record-change's own typecheck (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json) also exits 0.

Why the local run could not have caught it. This is the same gate the original PR body records as exit 3 / NOT MEASURED (PREREQUISITE NOT MET … nothing was measured) — an honest reading for a container with no whole-repo dist/. CI, with a full build, was the first place it actually ran. Recorded as a lesson: a NOT MEASURED gate is not a quiet pass, and one your diff can move is the one most likely to surprise you in CI.

Re-verification on the new head b7cffa0ec (supersedes the two sections above)

origin/main was merged again first — dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself among the files it had read stale copies of, which is the gate at issue. (Those upstream ledger edits are @objectstack/metadata graduations; the service-automation entry is untouched.)

  • Gate union re-derived and re-run in full: 41/41, 40 exit 0, 1 NOT MEASURED. With a whole-repo build present, check:dual-build-cjs-loads and check:type-check-debt now both exit 0 — they were the NOT MEASURED entries in the original run. The single remaining NOT MEASURED is node scripts/check-test-completeness.mjs (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.
  • Suites green, unchanged:service-automationTest Files 103 passed (103) / Tests 1223 passed (1223); trigger-record-changeTest Files 8 passed (8) / Tests 81 passed (81).

Clause-②: no — unchanged and unaffected: a test-file type annotation adds no exported symbol, no payload key and no accept-set change.

Deliberately not done

  • No packages/spec edit. It is single-owner and this PR is a consumer of resolveFlowTriggerKind. The change needed none — the temporary spec mutation in Leg C was an ablation, restored with hash proof and never committed.
  • The array-form divergence was not unified, per the card, the triage note and the resolver's own header. It stays a pre-check with its own pins on both sides.
  • No content/docs/releases/** edit. The release-notes input is the changeset.
  • No behaviour change was smuggled in. Leg A is the evidence, not the claim.

Verification, on the final head 39fcadf65

pnpm --filter '@objectstack/service-automation^...' build # closure first: spec resolves to dist/ here
pnpm --filter '@objectstack/service-automation' --filter '@objectstack/trigger-record-change' test
service-automation Test Files 103 passed (103) Tests 1223 passed (1223)
trigger-record-change Test Files 8 passed (8) Tests 81 passed (81)
os-verify-lock: VERDICT command-exit 0

Clause-②: no

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…d resolver (#14328)
`AutomationEngine.resolveTriggerBinding` kept a private copy of the chain that
decides which trigger kind a flow asks for, in parallel with
`@objectstack/spec`'s `resolveFlowTriggerKind` — the authoring-time mirror that
`defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
`validate-flow-trigger-readiness` already read. Nothing pinned the copies
together. The engine now takes the kind from the shared resolver and keeps only
the per-kind binding construction.
The array-form `triggerType` divergence is PRESERVED as an explicit pre-check
before the resolver call: spec answers no kind for it on purpose, and the engine
routes it to the record-change trigger only so that trigger can refuse it loudly
at bind time (#3457/#3481). Its ordering is load-bearing — array form outranks
`timeRelative`, which the resolver would otherwise answer for a start node
carrying both.
Two new guards close the drift: the per-kind switch is exhaustive over
`FlowTriggerKind` with a `never` default, so a kind added to spec fails this
package's type-check; and new pins assert every kind in `FLOW_TRIGGER_KINDS` is
reachable through the real engine, plus the preserved divergence end-to-end
against the real record-change trigger's refusal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 4 documentable anchor(s).

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

  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/permissions/capabilities.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in resolveTriggerBinding))

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

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v16.mdx(via AutomationEngine (symbol, a top-level class), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v17.mdx(via AutomationEngine (symbol, a top-level class), record_change (literal, a string literal in resolveTriggerBinding))

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

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 5 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 55eac1e1304fc187d837e69a0ef459422904a0c3packageMentionDocs.

Which tree this was computed on

This run read content/docs from b752853db28bfead3687cd69fe5117cc5f415669 — the merge of head b7cffa0ec82e354157d793827dd991f3eb3cbe52 into base 55eac1e1304fc187d837e69a0ef459422904a0c3, 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 b752853db28bfead3687cd69fe5117cc5f415669 && git checkout b752853db28bfead3687cd69fe5117cc5f415669
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 55eac1e1304fc187d837e69a0ef459422904a0c3 b7cffa0ec82e354157d793827dd991f3eb3cbe52 && git checkout -B drift-repro 55eac1e1304fc187d837e69a0ef459422904a0c3 && git merge --no-ff b7cffa0ec82e354157d793827dd991f3eb3cbe52
node scripts/docs-audit/affected-docs.mjs --json 55eac1e1304fc187d837e69a0ef459422904a0c3

⚠️ 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 55eac1e1304fc187d837e69a0ef459422904a0c3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

{
"issue": 14328,
"status": "done",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"premise_still_valid": true,
"summary": "AutomationEngine.resolveTriggerBinding now takes its trigger KIND from spec's resolveFlowTriggerKind and keeps only the per-kind binding construction; the array-form triggerType divergence is PRESERVED as an explicit pre-check BEFORE the resolver call, and its ordering is pinned (array form outranks timeRelative, which the resolver would otherwise answer for a start node carrying both). Two drift guards added: the per-kind switch is exhaustive over FlowTriggerKind with a never default (a kind added to spec fails this package's type-check), and a case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. Clause-2 re-derived from my own diff as 'no' and stated on its own line in the PR body: no exported symbol, no payload key, no accept-set change -- and measured, not argued, by ablation Leg A (reverting the unification reddened NOTHING, so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included). All five falsifiers checked and none fired: no spec edit needed; resolveFlowTriggerKind confirmed importable from the built @objectstack/spec/automation subpath; the two chains agree on every string-form flow (Leg A is the measurement); the array divergence is fully compatible with taking the kind from the resolver; nobody had wired it in. Three process notes, reported rather than smoothed: (1) I read AGENTS.md AFTER my first file edit, not before -- nothing in it was violated, but the ordering was wrong; (2) my ablation Leg C2 prediction was WRONG and I report it as such, with the diagnosis (see 'tests'); (3) the dispatch said to post this report as a PR comment while the standing os-dev contract says the card comment is the authoritative copy, so I posted it to BOTH (issue 14328 and PR 14994) rather than silently picking a side. Merged origin/main into the branch before deriving gates, because dispatch-gates.mjs reported STALE TREE and named the two files it had read stale copies of.",
"tests": "All on final head 39fcadf65, every exit code captured by redirect-then-read (never across a pipe).\nSUITES: pnpm --filter '@objectstack/service-automation^...' build (closure first -- service-automation resolves @objectstack/spec through exports to dist/), then pnpm --filter service-automation --filter trigger-record-change test => 'Test Files 103 passed (103) / Tests 1223 passed (1223)' and 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNEW PINS: 14 cases in packages/services/service-automation/src/flow-trigger-kind-shared-resolver.test.ts (7 precedence cases each asserting a LITERAL kind through the real engine, plus the audit surface and a FLOW_TRIGGER_KINDS reachability case), and 3 in packages/triggers/trigger-record-change/src/array-form-refusal-end-to-end.test.ts (engine -> real RecordChangeTrigger -> the refusal asserted by flow name, 'ARRAY', 'NOT bound / never fire', the record-after-write steer and the 3457 citation, zero hooks registered, plus an anti-vacuity control).\nGATES: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands => 41 commands, re-derived after the merge commit. 38 exited 0. 3 exited 3 and are NOT MEASURED, quoted from their own verdict text in the PR body: check-test-completeness ('Nothing was measured: this gate exited before parsing a single summary line ... It is NOT a finding'), check:dual-build-cjs-loads ('PREREQUISITE NOT MET -- this gate reads built output, and some package has no dist/ ... This is NOT a pass: nothing was measured'; its own self-test passed first, 93 cases), check:type-check-debt ('This is NOT a pass and NOT a finding: nothing was measured'; check:type-check-coverage exited 0 beside it). No gate exited 1.\nABLATION -- 4 legs, directions written down BEFORE running, driver carried trap ... EXIT INT TERM with absolute paths, and each leg rebuilt what it mutated:\n LEG A (revert the unification, engine.ts -> merge base): predicted NOTHING reddens and said in advance that this leg does NOT discriminate. Measured: 19/19 + 3/3 green, as predicted. This is the Clause-2 measurement.\n LEG B (delete the array-form pre-check): predicted a SPECIFIC red set. Measured exactly it -- '4 failed | 15 passed' in service-automation (3 of my array cases + the existing trigger-dispatch-observability array case) and '2 failed | 1 passed' end-to-end; all 7 precedence cases, the reachability case and the anti-vacuity control stayed GREEN. Discriminating.\n LEG C1 (spec's resolver loses its api branch, spec rebuilt; unified engine): predicted red on the api cases only. Measured '3 failed | 11 passed' -- both api precedence cases failing at test line 134 (the ENGINE half) plus 'reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS' at line 189.\n LEG C2 (same mutated spec, OLD private chain): I predicted FULLY GREEN and I WAS WRONG -- measured '2 failed | 17 passed'. Diagnosis: the two api cases redden at line 139, the half that queries resolveFlowTriggerKind DIRECTLY, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The engine half (line 134) and the reachability case both stayed GREEN. C1-vs-C2 is therefore still the leg that measures what this card buys, and the LINE NUMBERS are the measurement: with the unification a spec-side change moves the engine's answer; without it the engine is untouched. No leg reddened everything.\nMUTATION-REACHED-DISK: literal-text counts before/after on the mutated file each leg, each aborting on a miss -- resolveFlowTriggerKind 2 to 0, Array.isArray(config.triggerType) 1 to 0, f.type === 'api' 1 to 0. ONE MARKER WAS VACUOUS AND I REPORT IT: ablation-dist-preflight.mjs with the single-quoted SOURCE spelling matched only sourcemaps, because the bundler re-quotes to double quotes -- the tool caught it itself ('marker found ONLY in 6 sourcemap files ... Treat this run as void'). Leg C was RE-RUN with the bundling-stable marker 'triggerType === \"api\") return \"api\"': present in 6 built files before, 'absent from all 215 built files' after the mutated rebuild, present again after the restore rebuild, and the same 3-case red set reproduced.\nRESTORE PROOF: git hash-object equal to the HEAD blob for both mutated files (engine.ts 00401ee1..., flow-trigger-kind.ts 2e7c8137...), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree. The temporary packages/spec mutation was an ablation only -- never committed; the shipped diff touches no spec file.",
"mcp_calls": "0 -- REST via container curl for every GitHub read and write (probe returned 200); no MCP GitHub call was made this run",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

PM ACCEPT — domain:services execution seat

Session session_01AUF1NoViznQK32gqpK8wS8, reviewer of record. Verified against the tree at head 39fcadf65, ⛔ not against the report — the report is the thing under review, not evidence for it.

The load-bearing claim, re-read at the tree

The one way this card could have been got wrong was unifying the array-form divergence away. It was not:

  • the array-form pre-check block ends at engine.ts relative line 54; const kind = resolveFlowTriggerKind(flow); is at relative line 56. The pre-check genuinely precedes the resolver.
  • resolveTriggerBinding is still private; switch (kind) carries default: { const unhandledKind: never = kind; }, so a kind added to spec fails this package's type-check rather than falling through silently.
  • git diff --name-only origin/main...HEAD touches nopackages/spec/** and nocontent/docs/releases/**. Four files, +584/−56.

⭐ The in-code comment states the reason better than my dispatch order did, and the improvement is substantive rather than editorial. I said "preserve the divergence so the refusal stays loud." The dev's comment adds why the resolver must not answer for it at all: array form resolves to no kind on purpose, because reading it as "asks for a record-change trigger" would make defineStack demand a capability for a flow that can never use it, and would widen the lint rule's auto-triggered set. It also names a consequence I had not: the pre-check ordering preserves this method's own precedence — array form outranks timeRelative/schedule, which the resolver, blind to array form, would otherwise answer for a start node carrying both.

Clause-② no — accepted, and accepted because it is MEASURED

The declaration is re-derived from the dev's own diff rather than inherited from my claim comment, which is what I asked for. More importantly it is measured, not argued: Ablation Leg A reverted the unification and reddened nothing — so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included. That is the correct instrument for Clause-②: if reverting the change is unobservable, the change widens no accept set. No exported symbol, no payload key, resolveTriggerBinding still private. No contract-review tier is owed.

⭐ The wrong prediction, and why reporting it was the right call

Leg C2 was predicted fully green and measured 2 failed | 17 passed. The dev reported that as a wrong prediction rather than quietly rewriting the prediction to match, and diagnosed it correctly: the two api cases redden at test line 139, the half that queries resolveFlowTriggerKinddirectly, so that half reddens on any spec mutation regardless of the engine and cannot measure coupling. The engine half (line 134) and the reachability case stayed green.

The diagnosis is right, and the conclusion drawn from it is the useful one: C1-vs-C2 is still the leg that measures what this card buys, and the line numbers are the measurement — with the unification a spec-side change moves the engine's answer; without it the engine is untouched. A dev that had silently adjusted its prediction would have produced a tidier report and a worse one.

Two more disclosures I want on the record as good practice, not as faults: the vacuous marker (ablation-dist-preflight matched only sourcemaps because the bundler re-quotes single to double quotes) was caught by the tool, declared void, and the leg re-run with a bundling-stable marker — present in 6 built files before, absent from all 215 after, present again after restore. And the AGENTS.md read-ordering slip was self-reported though nothing in it was violated.

Landing state

Not armed yet. CI on 39fcadf65 reads 30 runs: 10 success, 3 skipped, 17 still in progress, zero failures. In-progress is not green, and "no failures" on a partially-run head is the same reading a head with no runs at all would give. Auto-merge goes on when the job-level read comes back complete with nothing red — not before.

pm:dispatched comes off card #14328 on MERGED.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

docs-drift-check disposition — 11 pages opened, zero doc edits. Recorded in full in the PR body (section "docs-drift-check disposition"); this comment is the thread-visible pointer, not a second os-dev-report.

Short form: the bot's hypothesis was tested rather than inherited — ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can describe behaviour this PR moved. All 10 hand-written pages were flagged through the record_change / time_relative string literals, which this PR neither renames nor re-values.

The two places named as able to falsify it have no documentation to go stale: no page in content/docs/ describes array-form triggerType handling, and none states what getTriggerBindingAudit or the boot banner report. Following the bot's own warning about pages that state a rule by its inputs, one unlisted page was found and read — content/docs/references/automation/time-relative-trigger.mdx — which is accurate here and is in any case marked AUTO-GENERATED — DO NOT EDIT from a packages/spec source this PR must not touch.

The 3 release-owned pages were read, not touched, and none is wrong — notably releases/v17.mdx:1536 ("array-form triggerType fails loudly instead of silently never firing") states exactly the behaviour this PR preserves as an explicit pre-check before the shared resolver. Nothing to file.

Generated by Claude Code


Generated by Claude Code

…tead of casting it away (#14328)
The type-check DEBT lane went red on the previous head: `@objectstack/service-automation`
measured 4 raw `tsc --noEmit` errors against a ledger entry recording 3.
The +1 was mine and it was in the new pin file:
src/flow-trigger-kind-shared-resolver.test.ts(250,21):
error TS7006: Parameter 'binding' implicitly has an 'any' type.
The recording trigger double was written as an object literal cast `as never`.
The cast erases the contextual type for `start`, so `binding` had no type to be
inferred from. Fixed by typing the double as the real `FlowTrigger` and the
capture array as `FlowTriggerBinding[]` — the same shape `engine.test.ts` already
uses — which restores the contextual type and removes the cast entirely.
This is a TYPE fix only. Both assertion lines in that case are byte-identical;
what the pin checks is unchanged, and the array-form divergence it guards is
untouched. The ledger entry is NOT raised: re-measured back to exactly the 3 it
records, same composition (TS2341 x3 in nested-region-parity.test.ts at
95/151/180, the pre-existing private-`flows` reads).
This package has no `typecheck` script, so its `tsc --noEmit` runs only in the
debt lane — which is why the error was invisible to the local gate run, where
that gate honestly reported PREREQUISITE NOT MET / nothing measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing conditions MET — held only by API quota, not by anything in this PR

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Recording state durably so it survives this session and so the next reader does not re-derive it.

CI on 4dd73b545 is fully green, read at job level rather than from an aggregate: 33 check runs — 30 success, 3 skipped, 0 pending, nothing not-green. Both checks that were red on the previous head now pass:

checkon 39fcadf65on 4dd73b545
Type Check · debt ledgerfailuresuccess
TypeScript Type Checkfailuresuccess
Lint & Repo Gatessuccess

mergeable_state: clean.

The patch round, verified at the tree

The two reds were one root cause: TypeScript Type Check is an aggregator, and three of its four lanes (typecheck-source-gates, typecheck-workspace, typecheck-consumers) passed on the red head — it was red purely because it re-reports typecheck-debt.

The fix (4dd73b545, one file, +11/−4) replaces an … as never cast — which erased the contextual type and left a callback parameter implicitly any (TS7006) — with a properly typed const trigger: FlowTrigger. Verified by reading the diff, not the report:

  • started is typed FlowTriggerBinding[] instead of a loose inline shape — stronger, not weaker;
  • the binding as (typeof started)[number] cast is removed, not added;
  • no assertion changedexpect(started).toHaveLength(1) and everything below it is untouched.

⛔ Nothing was ledgered. The shrink-only ratchet was not raised, and no @ts-expect-error was used — the gate's own text forecloses both, and the author's remedy was taken instead.

⛔ What is blocking, and it is not this PR

The two remaining actions — mark ready for review, then enable auto-merge (squash) — are both GraphQL-only operations on GitHub. This session's GraphQL endpoint serves only a pinned set of PR-review operations ("This GraphQL query is not enabled for this session"), and REST's PATCH /pulls/:n silently ignores draft (returns 200 with draft unchanged — verified, not assumed). So both steps require the MCP GitHub tools, and that pool is currently exhausted:

API rate limit already exceeded for user ID 319429713

Per this seat's standing rule the response is to back off rather than poll or loop-retry. The repo-scoped REST pool is separate and healthy (15000/15000), which is how this comment is being written — but it cannot serve the two remaining operations.

Order when quota returns, and the order matters: mark ready first, then arm auto-merge — auto-merge does not survive a draft conversion, so arming first would be undone by the undraft.

No carriers to clear: Clause-②: no, measured by ablation Leg A (reverting the unification reddened nothing), so no contract-review tier is owed. PM ACCEPT is comment 5529090987.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 3, 2026 17:51
@os-sales
os-sales added this pull request to the merge queueSep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

Patch round 1 -- supersedes the round-0 report above for gate/test readings; the implementation, pins and ablation are unchanged.

{
"issue": 14328,
"status": "done",
"round": "patch round 1",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"head": "b7cffa0ec",
"premise_still_valid": true,
"summary": "Patch round 1: the Type Check debt lane was red on 39fcadf65 and it was mine. Measured rather than guessed -- built the closure, ran the package's own tsc --noEmit, and got 4 errors: 3 TS2341 in src/nested-region-parity.test.ts at 95/151/180 (exactly the pile the ledger entry records and itemises) plus ONE new TS7006 at src/flow-trigger-kind-shared-resolver.test.ts(250,21), 'Parameter binding implicitly has an any type'. Cause: the recording trigger double was an object literal cast 'as never', and the cast erases the contextual type for start, so binding had nothing to infer from. Fixed as a TYPE fix only -- the double is now typed as the real FlowTrigger and the capture array as FlowTriggerBinding[], the same shape engine.test.ts already uses, and the cast is gone. BOTH assertion lines are byte-identical: git diff touches no line containing 'expect', so what the pin checks and the array-form divergence it guards are unchanged. The ledger entry was NOT raised, nothing was ts-expect-error'd, no tsconfig loosened, no test skipped. The second red check (TypeScript Type Check) was the aggregator re-reporting the same lane -- its other three lanes were green on that head -- so one fix cleared both, and the aggregator's narrowing was confirmed by measurement rather than taken on trust: this package has no typecheck script, so its tsc --noEmit runs only in the debt lane, which is exactly why the original local gate run honestly reported that gate as exit 3 / NOT MEASURED and could not have caught it. Merged origin/main again before re-verifying because dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself -- the gate at issue -- among the stale files; those upstream ledger edits are metadata graduations and the service-automation entry is untouched. Two observations for the seat, neither acted on: (1) the PR read back as draft:false after my body PATCH -- I did not set that and did not touch it, since REST PATCH /pulls does not accept 'draft' and I sent only 'body'; it is consistent with the coordinator's stated intent to do the undrafting and arming. (2) During the union re-run my first 'tail --pid' attached to a stale pid and reported the runner gone at 25/41 while the real runner was still holding the lock; I re-attached to the real pid and completed 41/41 rather than reporting the partial set.",
"tests": "All on final head b7cffa0ec, exit codes captured by redirect-then-read.\nTSC (the actual measurement): cd packages/services/service-automation && tsc --noEmit => exit 2, 3 errors, all TS2341 'Property flows is private' in src/nested-region-parity.test.ts at 95/151/180 -- the ledger's recorded number AND composition. Before the fix the same command reported 4, the extra being src/flow-trigger-kind-shared-resolver.test.ts(250,21) TS7006.\nDEBT GATE (first time it has actually run in this container -- it needed the whole-repo build that made it exit 3 before): pnpm check:type-check-debt => EXIT=0, both halves. Verdict text: '✓ check:type-check-coverage --self-test -- 48 semantic case(s) + 68 observation case(s) + 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s) + 18 exit-code case(s) hold.' and 'check-type-check-coverage --re-measure: OK -- 18 ledger entr(ies) re-measured in 192.9s, 218 raw tsc error(s) total, none above its recorded number.'\nSIBLING PACKAGE: pnpm --filter '@objectstack/trigger-record-change' typecheck => exit 0 (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json). NOTE: an earlier run of it showed TS2307 'Cannot find module @objectstack/service-automation' in two PRE-EXISTING files; that was an unmet prerequisite in a fresh worktree (service-automation had no built .d.ts because I had built only the '^...' closure, which excludes the package itself), not a finding -- building service-automation cleared it. Reported because it reads exactly like a real failure.\nGATE UNION re-derived and re-run in full on the final head: 41/41 recorded, 40 exit 0, 1 exit 3. With a whole-repo build present, check:dual-build-cjs-loads AND check:type-check-debt now both exit 0 -- they were two of the three NOT MEASURED entries in the pre-patch run. The single remaining NOT MEASURED is 'node scripts/check-test-completeness.mjs' (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.\nWHOLE-REPO BUILD: pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 => 71 successful, 71 total, exit 0 (run twice: once pre-merge, once post-merge).\nSUITES: service-automation 'Test Files 103 passed (103) / Tests 1223 passed (1223)'; trigger-record-change 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNO NEW ABLATION for this round: the change is a test-file type annotation with byte-identical assertions, so there is no behaviour to ablate. The original 4-leg ablation stands unchanged on the engine diff, which this round did not touch.",
"mcp_calls": "0 for this round as well -- REST via container curl for every GitHub read and write; still zero MCP GitHub calls across the whole card",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Blocked on enqueue — auto-merge accepts and does not persist. Stating it once.

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Head b7cffa0ec.

This PR is done on every axis I control. CI job-level on b7cffa0ec: 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green, Type Check · debt ledger and TypeScript Type Check both success. Non-draft. Clause-②: no, so no carriers are owed. PM ACCEPT is comment 5529090987; the patch round is verified at the tree (assertions byte-identical, ledger not raised).

What is actually blocking

Auto-merge does not persist. Twice now:

timeactionresult
17:51:34Zenable auto-mergeAPI reported enabled
17:54:04Zread backauto_merge: none, not in the queue
~17:55Zre-armed (once — the limit)API reported enabled
17:57:26Zread backauto_merge: none, not in the queue

Throughout, mergeable_state reads unknown and mergeable is null — GitHub is not returning a computed mergeability for this PR, and auto-merge does not survive that state.

⛔ It is NOT a merge conflict — measured, not assumed

merge-base(origin/main, PR head) → files changed by BOTH sides since it:
(empty)

The branch already merged origin/main at 77a532d67 (commit b7cffa0ec), and no file this PR touches is touched by anything that landed on main since. So there is nothing to resolve, and pushing a further merge commit would be a no-op that costs a full CI cycle.

The likeliest reading, offered as a hypothesis rather than a conclusion: main is advancing every few minutes while the merge queue drains (it moved fddfc8db0 → a4e4d2d78 inside this window, with 5 entries queued), and each advance re-invalidates the mergeability computation before auto-merge can act on it.

What I am doing about it

⛔ Not re-arming again — the standing rule is one re-arm, and a third would be the loop-retry the rule exists to prevent. ⛔ Not pushing an empty commit and ⛔ not closing/reopening to kick it; both are forbidden and neither addresses an uncomputed mergeability.

This PR stays watched with a check-in scheduled until it merges or the blocker changes. If mergeable_state resolves to clean and it is still unqueued, I arm again then — that is a different state, not a retry of this one.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

⛔ Correction to my comment above (5529902021) — the diagnosis was wrong

domain:services execution seat. This PR did enqueue. A pull_request.enqueued event fired at 17:52:29Z, so the arm at 17:51:34Z worked, and my headline "auto-merge accepts and does not persist" is false.

Two reading errors produced it, both mine:

  1. I read the queue 28 seconds too early. My git ls-remote snapshot was taken at 17:52:01Z; the enqueue happened at 17:52:29Z. I recorded "not in the queue" from a read that predates the event and then treated it as evidence about the arm.
  2. I misread auto_merge: none as the arm being dropped. GitHub clearsauto_merge when a PR enters the merge queue — the setting has done its job. So auto_merge: noneafter an enqueue is the expected state, not a failure. My table read that as the arm evaporating twice.

⚠️The second arm (~17:55Z) was therefore unnecessary, and I should not have made it: the PR was already queued when I sent it. It was harmless but it was based on the same misreading.

What the evidence actually supports

Enqueued 17:52:29Z; absent from the queue refs at 17:54:04Z, 17:57:26Z and 17:59:23Z; merged_at still null. So the real event is a dequeue without merging, some time between 17:52:29Z and 17:54:04Z — not a failure to arm. The removal notice GitHub sends for that is best-effort and did not arrive, which is why I only have the endpoints and not the cause.

⛔ I am not now asserting a cause for the dequeue. The candidates I can see — the queue's own CI run on the merged-with-main candidate, or an eviction when main advanced (fddfc8db0 → a4e4d2d78 inside this window) — are hypotheses, and I have already published one wrong diagnosis on this PR today. The next reading, not another guess, decides it.

What stands from that comment

Unchanged and still verified: CI on b7cffa0ec is 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green; the PR is non-draft; Clause-②: no so no carriers are owed; and there is no merge conflict — the set of files changed by both origin/main and this PR since their merge-base is empty, measured rather than assumed.

Also unchanged: ⛔ no empty commit, ⛔ no close-and-reopen, ⛔ no merge commit for a conflict that does not exist.

Next action is to re-read the queue and merged_at and, if it is genuinely out of the queue with a computed mergeable state, arm once against that state — which is a response to a dequeue, not a third retry of an arm that in fact succeeded.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-automation: resolveTriggerBinding keeps a private copy of the trigger-kind chain that spec's resolveFlowTriggerKind now mirrors

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(service-automation): take the trigger kind from spec's shared resolver - #14994

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver
Sep 3, 2026
Merged

refactor(service-automation): take the trigger kind from spec's shared resolver#14994
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14328

AutomationEngine.resolveTriggerBinding kept a private copy of the chain that decides which trigger kind a flow asks for, in parallel with @objectstack/spec's resolveFlowTriggerKind — the authoring-time mirror of the same rule that defineStack's trigger-capability refusal and @objectstack/lint's validate-flow-trigger-readiness already read. Nothing pinned the two copies together, so a branch added to one side would leave defineStack accepting a stack the runtime leaves inert, or refusing one it would arm.

The shape implemented

resolveTriggerBinding now takes its kind from resolveFlowTriggerKind(flow) and keeps only the per-kind binding construction — which start-node fields each trigger needs. getTriggerBindingAudit and the boot banner therefore name the kind authoring named, by construction.

resolveFlowTriggerKind is one more name on an import statement engine.ts already had (@objectstack/spec/automation); @objectstack/spec is a real dependencies entry of service-automation, so there is no new package edge.

Two guards close the drift the card is about, one static and one at run time:

  • the per-kind switch is exhaustive over FlowTriggerKind with a never default, so a kind added to spec fails this package's type-check until its binding shape is written. At run time (a spec build ahead of this one) it falls back to today's behaviour — no binding — rather than throwing inside the boot audit;
  • a new case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. That is the case the ablation shows is the actual coupling measurement.

The array-form divergence, and how it is preserved

The ARRAY form of triggerType (['record-after-create', 'record-after-delete']) resolves to no kind in spec, deliberately, and the resolver's own header says why: multi-event unions are unsupported (#3457), and reading the shape as "asks for a record-change trigger" would have defineStack demand a capability the flow can never use and would widen the lint rule's auto-triggered set. The engine routes it to the record-change trigger anyway — a diagnostic route — so that trigger refuses it loudly at bind time (#3481) instead of the flow folding into "manual" and vanishing from every surface.

It is kept as an explicit pre-check BEFORE the resolver call, never folded into it. The ordering is load-bearing on its own: array form outranks timeRelative, which the resolver — blind to the array — would otherwise answer for a start node carrying both. Moving the pre-check after the resolver call silently re-routes that flow and swaps the loud refusal for a sweep, so it has its own pin.

Clause-②: no

Re-derived from this diff, not inherited. No exported symbol is added or removed (resolveTriggerBinding is private; resolveFlowTriggerKind is imported, not re-exported), no payload key changes, and no accept-set moves.

The one place the seat named as able to flip it — whether the publicgetTriggerBindingAudit or the boot banner reports anything different for any flow, the array-form case being the candidate — was measured rather than argued, and it does not flip:

  • Ablation Leg A reverted the unification wholesale (engine.ts → the merge base) with the new pins in place. Nothing reddened: 19/19 service-automation cases and 3/3 trigger-record-change cases green. Old chain and new produce the same answer on every case the pins cover, array form included.
  • The array-form route is unchanged in both fields the refusal is built from: event is still the joined token that maps to no hook, config.triggerType still carries the raw array. Pinned directly.
  • 1,223 existing service-automation cases and 81 trigger-record-change cases pass unchanged.

Pins, and what each one catches

pincatches
7 precedence cases in flow-trigger-kind-shared-resolver.test.ts, each asserting a literal kind through the real engine (registerFlowgetFlowRuntimeStates())a unification that mis-maps a case. The literal is the half that can fail alone — expect(engineKind).toBe(resolveFlowTriggerKind(flow)) on its own is satisfied by two wrong answers that agree, and by undefined === undefined
the same cases' second half, against resolveFlowTriggerKind on the flow the engine storeda registration that rewrote the start node under the literal
names the same kind on getTriggerBindingAuditthe card's stated payoff, on the public surface the boot banner prints
reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDSthe coupling itself — a kind added to spec that the engine has no binding shape for. Reads only engine output, which is what makes it the one case that discriminates coupled from merely-agreeing (see Leg C)
4 array-form cases (routes to record_change though spec answers no kind; pre-check outranks timeRelative; raw array + joined event forwarded; an array with no record-* element is left to the resolver)"unifying" the divergence away, and re-ordering the pre-check behind the resolver
array-form-refusal-end-to-end.test.ts (new, in @objectstack/trigger-record-change)the refusal itself — engine → real RecordChangeTrigger → the warn, asserted by flow name, "ARRAY", "NOT bound / never fire", the record-after-write steer and #3457, plus zero hooks registered. Its third case is an anti-vacuity control: a legitimate single record-after-update token draws no warn and arms afterUpdate

The e2e file lives in trigger-record-change because that is the only side of the edge where both halves exist: @objectstack/service-automation is a devDependency there, and the trigger is deliberately not a dependency of the engine. Same split — and same dist/-resolution note — as the existing reentrant-start-condition.test.ts.

Ablation

Directions were written down before any leg ran. Resolution facts that shape the legs, measured not assumed: service-automation's own tests read engine.ts from source and @objectstack/spec through exports to dist/; trigger-record-change's tests read @objectstack/service-automation through exports to dist/ (both from check:test-source-alias's KNOWN_UNALIASED_TEST_IMPORTS). @objectstack/spec is external in the engine's bundle, so rebuilding spec alone reaches the engine's dist. Every leg rebuilt what it mutated; the driver carried trap … EXIT INT TERM with absolute paths.

legmutationpredictedmeasured
Arevert the unification (engine.ts → merge base)nothing reddens — a behaviour-preserving refactor cannot redden behaviour pins; stated in advance as not discriminating✅ as predicted. 19/19 + 3/3 green
Bkeep the unification, delete the array-form pre-check (the "unify it away" mistake)red: 3 array cases here + the existing trigger-dispatch-observability array case + 2 of 3 e2e cases; green: all 7 precedence cases, the reachability case, and the e2e anti-vacuity case✅ exactly. 4 failed | 15 passed in service-automation, 2 failed | 1 passed e2e. Reddened: routes array form to record_change…, keeps the array pre-check AHEAD of the resolver…, hands the record-change trigger the raw array…, hands an array triggerType to the record_change trigger…, refuses the array form by name…, gets there because the ENGINE routed it…
C1spec's resolver loses its api branch, spec rebuilt; unified enginered on the api cases only3 failed | 11 passed: both api precedence cases (failing at line 134, the engine half) and reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS (line 189)
C2same mutated spec, old private chain❌ I predicted fully green; it was 2 failed | 17 passedthe two api cases redden here too — but at line 139, the half that queries resolveFlowTriggerKinddirectly. Line 134, the engine half, stayed green, and the reachability case stayed green

C1 vs C2 is the leg that measures what this card buys, and the line numbers are the measurement. With the unification, a spec-side change moves the engine's answer (line 134 reddens, reachability reddens). Without it, the engine is untouched by the same spec change (line 134 green, reachability green) and only the direct spec query reddens. That is the drift the card describes, demonstrated in both directions.

My C2 prediction was wrong and the reason is worth recording rather than smoothing over: a pin's "and to what spec answers" half asks spec directly, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The coupling measurements are the engine-half literal and the reachability case. Neither leg reddened everything.

Mutation-reached-disk proof. Literal-text counts before/after on the mutated file each time (resolveFlowTriggerKind 2 → 0; Array.isArray(config.triggerType) 1 → 0; f.type === 'api' 1 → 0), each aborting the leg on a miss. One marker was initially vacuous and is reported as such: scripts/ablation-dist-preflight.mjs with the single-quoted source spelling f.type === 'api' matched only sourcemaps, because the bundler re-quotes to double quotes — the tool caught it itself (✗ … found ONLY in 6 sourcemap files … Treat this run as void). Leg C was re-run with the bundling-stable marker triggerType === "api") return "api": ✓ marker present in 6 built files before, ✓ marker absent from all 215 built files after the mutated rebuild, ✓ present again after the restore rebuild, and the same 3-case red set reproduced.

Restore proof.git hash-object equal to the HEAD blob for both mutated files (engine.ts00401ee1…, flow-trigger-kind.ts2e7c8137…), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on the merged head (origin/main merged in first — the earlier derivation warned STALE TREE and named the two files it read stale copies of, so the list below is the one derived from a tree someone is on). Every exit code captured by redirect-then-read, never across a pipe.

41 commands, all captured by redirect-then-read. 38 exited 0; 3 exited 3 and are NOT MEASURED — neither green nor red — quoted below from each gate's own verdict text.

Exit 0 (38):node scripts/check-adr-0087-registration.mjs · node scripts/check-changeset-no-major.mjs · node scripts/check-ci-filter-parity.mjs · node scripts/check-closing-keyword-parity.mjs · node scripts/check-comment-mask-adoption.mjs · node scripts/check-comment-mask-corpus.mjs · node scripts/check-cross-package-test-inputs.mjs · node scripts/check-empty-changeset.mjs · node scripts/check-keyed-text-bounds.mjs · node scripts/check-plugin-teardown-shape.mjs · node scripts/check-shard-attestation.mjs · node scripts/check-system-context-census.mjs · node scripts/check-tenant-audit-census.mjs · node scripts/check-undeclared-dep-imports.mjs · node scripts/docs-audit/check-affected-docs.mjs · node scripts/docs-audit/check-drift-comment.mjs · node scripts/pm/check-half-states.mjs · node scripts/pm/release-rehearsal-clone.mjs --self-test · pnpm check:changeset-gate-self-tests · pnpm check:cross-package-test-inputs · pnpm check:dispatcher-error-vocabulary · pnpm check:doc-authoring · pnpm check:engine-double-contract · pnpm check:logger-receiver-detach · pnpm check:nul-bytes · pnpm check:objectql-double-limit · pnpm check:objectui-changeset · pnpm check:page-declaration-shape · pnpm check:pm-half-states · pnpm check:published-files · pnpm check:query-options-erasure · pnpm check:refd-timer-probe · pnpm check:slot-lookup · pnpm check:test-source-alias · pnpm check:type-check-coverage · pnpm check:type-source-resolution · pnpm check:watch-hint-literal · pnpm check:where-matcher

NOT MEASURED (exit 3, 3):

  • node scripts/check-test-completeness.mjs — exit 3

Nothing was measured: this gate exited before parsing a single summary line, so
this result says NOTHING about whether every test vitest counted actually ran,
nor about whether every scheduled package reported.
⛔ It is NOT a finding, and it is not evidence that anything in the tree is wrong.

It wants a saved turbo run test log; running the family locally, its own fix line says to "record this gate as NOT MEASURED".

  • pnpm check:dual-build-cjs-loads — exit 3

PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.
· @objectstack/hono#. -> ./dist/index.js (no packages/adapters/hono/dist)
… 40 more
Run pnpm build first. ⛔ This is NOT a pass: nothing was measured.

Its own self-test passed first (✓ check-dual-build-cjs-loads self-test: 93 cases pass); only the repo-wide built-output leg is unmet. Needs a whole-repo pnpm build, which is CI's run, not mine.

  • pnpm check:type-check-debt — exit 3

⛔ This is NOT a pass and NOT a finding: nothing was measured, so this run says
NOTHING about whether any DEBT or TEST_DEBT number is still correct.

Same cause — it asks for the closure build lint.yml does first (pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'). Note pnpm check:type-check-coverage exited 0 beside it.

Two notes on the derived list rather than trusting it blind: dispatch-gates.mjs keys entries by script path, so a script CI invokes twice collapses to one entry; and the derived list excludes CI's always-runs tail. Re-derived after the merge commit that changed the file set, and the union above was run on the final head 39fcadf65 — same tree as the suite run below.

docs-drift-check disposition — 11 pages opened, zero doc edits, and why

The bot is advisory, so this is the disposition rather than a skip. Its hypothesis — that nothing needs an edit because the refactor is behaviour-neutral — was tested, not inherited: ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can be describing behaviour this PR moved. The bot also discloses that it computed on merge commit f8f059e9a631576a850615bf7116350bba098063 rather than this PR head, and that a page stating the rule by its inputs shares no identifier with the emitter and cannot be listed at all — so the list was treated as a starting point, not the population.

Its 10 hand-written pages. All flagged through the record_change / time_relative string literals in resolveTriggerBinding, which this PR neither renames nor re-values.

pagewhy no edit
automation/flows.mdx (flagged twice, via time_relative too)Three passages read. :1411 "Registration is not arming … defineStack refuses a stack … naming the flow, the trigger kind it resolved" describes the authoring side, which already read resolveFlowTriggerKind before this PR. :1818 the single-event triggerType table — every token still resolves to record_change. :1742 the time-relative sweep, shown with a schedule cadence besidetimeRelative: that is precisely the precedence case, and it is preserved (the resolver ranks time_relative ahead of schedule for the same reason the private chain did). This PR makes that documented ordering pinned rather than incidental
permissions/capabilities.mdx (flagged twice, via time_relative too):28 lists record_change / schedule / time_relative / api as the flow kinds needing triggers. That set is FLOW_TRIGGER_KINDS, unchanged — and now asserted reachable through the real engine by a new case
automation/approvals.mdx, automation/workflows.mdx, concepts/architecture.mdx, getting-started/common-patterns.mdxAuthoring examples: type: 'record_change' flows with single-string triggerType tokens. Same kind, same binding, same fields
automation/hooks.mdxThe hooks-vs-flows chooser, naming record_change and schedule/timeRelative as options. Unchanged
api/plugin-endpoints.mdx, kernel/services-checklist.mdx, protocol/objectql/schema.mdxSingle passing mentions of record_change as a concept

The blind spot the bot warned about, checked. Searching the docs tree for the rule stated by its inputs (timeRelative, array-form / multi-event prose) surfaced one page not on the list: content/docs/references/automation/time-relative-trigger.mdx. It describes config.timeRelative with an optional schedule cadence — accurate under this PR, and in any case it is marked ⚠️ AUTO-GENERATED — DO NOT EDIT, generated from packages/spec/src/automation/time-relative-trigger.zod.ts, which this PR must not touch. No page anywhere in content/docs/ describes array-form triggerType handling, and none mentions getTriggerBindingAudit or the boot banner's contents, so the two places the coordinator named as able to falsify the hypothesis have no documentation to go stale.

The 3 release-owned pages: read, not touched, and none is wrong.releases/v17.mdx:1536 states "array-form triggerType fails loudly instead of silently never firing" — that is exactly the behaviour this PR preserves as an explicit pre-check, so it remains correct. releases/v16.mdx:393 (the time_relative trigger) and :409 (the kernel:bootstrapped binding audit warning per enabled-but-unbound flow) and releases/v12.mdx:159 are all still accurate. Nothing to file.

Patch round 1 — the type-check DEBT lane was red, and it was mine

Two red checks, one root cause.Type Check · debt ledger failed on 39fcadf65 with @objectstack/service-automation: DEBT records 3 raw tsc error(s), tsc --noEmit now reports 4 (+1), and TypeScript Type Check was red only because it aggregates that lane (its other three — typecheck-source-gates, typecheck-workspace, typecheck-consumers — were green on the same head). One fix clears both.

Measured, not guessed. Built the closure, then ran the package's tsc --noEmit directly:

src/flow-trigger-kind-shared-resolver.test.ts(250,21): error TS7006: Parameter 'binding' implicitly has an 'any' type.
src/nested-region-parity.test.ts(95,25): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(151,20): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(180,20): error TS2341: Property 'flows' is private …

The three TS2341 are the ledger's recorded pile, at exactly the lines its note itemises (95/151/180). The +1 was mine, in the new pin file — consistent with the aggregator's narrowing, since this package has no typecheck script, so its tsc --noEmit runs only in the debt lane.

The cause. The recording trigger double was an object literal cast as never. The cast erases the contextual type for start, leaving binding with nothing to infer from.

The fix is a type fix. The double is now typed as the real FlowTrigger, the capture array as FlowTriggerBinding[] — the shape engine.test.ts already uses — and the cast is gone. Both assertion lines in that case are byte-identical; git diff touches no line containing expect. What the pin checks, and the array-form divergence it guards, are unchanged.

Proof.

tsc --noEmit (packages/services/service-automation) → 3 errors, exit 2
the ledger's 3 exactly, same composition (TS2341 ×3, nested-region-parity.test.ts 95/151/180)
pnpm check:type-check-debt → EXIT=0 (captured by redirect-then-read)
✓ check:type-check-coverage --self-test — 48 semantic case(s) + 68 observation case(s)
+ 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s)
+ 18 exit-code case(s) hold.
check-type-check-coverage --re-measure: OK — 18 ledger entr(ies) re-measured in 192.9s,
218 raw tsc error(s) total, none above its recorded number.

⛔ The ledger entry was not raised, nothing was @ts-expect-error-ed, no tsconfig loosened, no test skipped. @objectstack/trigger-record-change's own typecheck (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json) also exits 0.

Why the local run could not have caught it. This is the same gate the original PR body records as exit 3 / NOT MEASURED (PREREQUISITE NOT MET … nothing was measured) — an honest reading for a container with no whole-repo dist/. CI, with a full build, was the first place it actually ran. Recorded as a lesson: a NOT MEASURED gate is not a quiet pass, and one your diff can move is the one most likely to surprise you in CI.

Re-verification on the new head b7cffa0ec (supersedes the two sections above)

origin/main was merged again first — dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself among the files it had read stale copies of, which is the gate at issue. (Those upstream ledger edits are @objectstack/metadata graduations; the service-automation entry is untouched.)

  • Gate union re-derived and re-run in full: 41/41, 40 exit 0, 1 NOT MEASURED. With a whole-repo build present, check:dual-build-cjs-loads and check:type-check-debt now both exit 0 — they were the NOT MEASURED entries in the original run. The single remaining NOT MEASURED is node scripts/check-test-completeness.mjs (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.
  • Suites green, unchanged:service-automationTest Files 103 passed (103) / Tests 1223 passed (1223); trigger-record-changeTest Files 8 passed (8) / Tests 81 passed (81).

Clause-②: no — unchanged and unaffected: a test-file type annotation adds no exported symbol, no payload key and no accept-set change.

Deliberately not done

  • No packages/spec edit. It is single-owner and this PR is a consumer of resolveFlowTriggerKind. The change needed none — the temporary spec mutation in Leg C was an ablation, restored with hash proof and never committed.
  • The array-form divergence was not unified, per the card, the triage note and the resolver's own header. It stays a pre-check with its own pins on both sides.
  • No content/docs/releases/** edit. The release-notes input is the changeset.
  • No behaviour change was smuggled in. Leg A is the evidence, not the claim.

Verification, on the final head 39fcadf65

pnpm --filter '@objectstack/service-automation^...' build # closure first: spec resolves to dist/ here
pnpm --filter '@objectstack/service-automation' --filter '@objectstack/trigger-record-change' test
service-automation Test Files 103 passed (103) Tests 1223 passed (1223)
trigger-record-change Test Files 8 passed (8) Tests 81 passed (81)
os-verify-lock: VERDICT command-exit 0

Clause-②: no

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…d resolver (#14328)
`AutomationEngine.resolveTriggerBinding` kept a private copy of the chain that
decides which trigger kind a flow asks for, in parallel with
`@objectstack/spec`'s `resolveFlowTriggerKind` — the authoring-time mirror that
`defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
`validate-flow-trigger-readiness` already read. Nothing pinned the copies
together. The engine now takes the kind from the shared resolver and keeps only
the per-kind binding construction.
The array-form `triggerType` divergence is PRESERVED as an explicit pre-check
before the resolver call: spec answers no kind for it on purpose, and the engine
routes it to the record-change trigger only so that trigger can refuse it loudly
at bind time (#3457/#3481). Its ordering is load-bearing — array form outranks
`timeRelative`, which the resolver would otherwise answer for a start node
carrying both.
Two new guards close the drift: the per-kind switch is exhaustive over
`FlowTriggerKind` with a `never` default, so a kind added to spec fails this
package's type-check; and new pins assert every kind in `FLOW_TRIGGER_KINDS` is
reachable through the real engine, plus the preserved divergence end-to-end
against the real record-change trigger's refusal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 4 documentable anchor(s).

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

  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/permissions/capabilities.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in resolveTriggerBinding))

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

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v16.mdx(via AutomationEngine (symbol, a top-level class), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v17.mdx(via AutomationEngine (symbol, a top-level class), record_change (literal, a string literal in resolveTriggerBinding))

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

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 5 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 55eac1e1304fc187d837e69a0ef459422904a0c3packageMentionDocs.

Which tree this was computed on

This run read content/docs from b752853db28bfead3687cd69fe5117cc5f415669 — the merge of head b7cffa0ec82e354157d793827dd991f3eb3cbe52 into base 55eac1e1304fc187d837e69a0ef459422904a0c3, 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 b752853db28bfead3687cd69fe5117cc5f415669 && git checkout b752853db28bfead3687cd69fe5117cc5f415669
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 55eac1e1304fc187d837e69a0ef459422904a0c3 b7cffa0ec82e354157d793827dd991f3eb3cbe52 && git checkout -B drift-repro 55eac1e1304fc187d837e69a0ef459422904a0c3 && git merge --no-ff b7cffa0ec82e354157d793827dd991f3eb3cbe52
node scripts/docs-audit/affected-docs.mjs --json 55eac1e1304fc187d837e69a0ef459422904a0c3

⚠️ 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 55eac1e1304fc187d837e69a0ef459422904a0c3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

{
"issue": 14328,
"status": "done",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"premise_still_valid": true,
"summary": "AutomationEngine.resolveTriggerBinding now takes its trigger KIND from spec's resolveFlowTriggerKind and keeps only the per-kind binding construction; the array-form triggerType divergence is PRESERVED as an explicit pre-check BEFORE the resolver call, and its ordering is pinned (array form outranks timeRelative, which the resolver would otherwise answer for a start node carrying both). Two drift guards added: the per-kind switch is exhaustive over FlowTriggerKind with a never default (a kind added to spec fails this package's type-check), and a case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. Clause-2 re-derived from my own diff as 'no' and stated on its own line in the PR body: no exported symbol, no payload key, no accept-set change -- and measured, not argued, by ablation Leg A (reverting the unification reddened NOTHING, so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included). All five falsifiers checked and none fired: no spec edit needed; resolveFlowTriggerKind confirmed importable from the built @objectstack/spec/automation subpath; the two chains agree on every string-form flow (Leg A is the measurement); the array divergence is fully compatible with taking the kind from the resolver; nobody had wired it in. Three process notes, reported rather than smoothed: (1) I read AGENTS.md AFTER my first file edit, not before -- nothing in it was violated, but the ordering was wrong; (2) my ablation Leg C2 prediction was WRONG and I report it as such, with the diagnosis (see 'tests'); (3) the dispatch said to post this report as a PR comment while the standing os-dev contract says the card comment is the authoritative copy, so I posted it to BOTH (issue 14328 and PR 14994) rather than silently picking a side. Merged origin/main into the branch before deriving gates, because dispatch-gates.mjs reported STALE TREE and named the two files it had read stale copies of.",
"tests": "All on final head 39fcadf65, every exit code captured by redirect-then-read (never across a pipe).\nSUITES: pnpm --filter '@objectstack/service-automation^...' build (closure first -- service-automation resolves @objectstack/spec through exports to dist/), then pnpm --filter service-automation --filter trigger-record-change test => 'Test Files 103 passed (103) / Tests 1223 passed (1223)' and 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNEW PINS: 14 cases in packages/services/service-automation/src/flow-trigger-kind-shared-resolver.test.ts (7 precedence cases each asserting a LITERAL kind through the real engine, plus the audit surface and a FLOW_TRIGGER_KINDS reachability case), and 3 in packages/triggers/trigger-record-change/src/array-form-refusal-end-to-end.test.ts (engine -> real RecordChangeTrigger -> the refusal asserted by flow name, 'ARRAY', 'NOT bound / never fire', the record-after-write steer and the 3457 citation, zero hooks registered, plus an anti-vacuity control).\nGATES: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands => 41 commands, re-derived after the merge commit. 38 exited 0. 3 exited 3 and are NOT MEASURED, quoted from their own verdict text in the PR body: check-test-completeness ('Nothing was measured: this gate exited before parsing a single summary line ... It is NOT a finding'), check:dual-build-cjs-loads ('PREREQUISITE NOT MET -- this gate reads built output, and some package has no dist/ ... This is NOT a pass: nothing was measured'; its own self-test passed first, 93 cases), check:type-check-debt ('This is NOT a pass and NOT a finding: nothing was measured'; check:type-check-coverage exited 0 beside it). No gate exited 1.\nABLATION -- 4 legs, directions written down BEFORE running, driver carried trap ... EXIT INT TERM with absolute paths, and each leg rebuilt what it mutated:\n LEG A (revert the unification, engine.ts -> merge base): predicted NOTHING reddens and said in advance that this leg does NOT discriminate. Measured: 19/19 + 3/3 green, as predicted. This is the Clause-2 measurement.\n LEG B (delete the array-form pre-check): predicted a SPECIFIC red set. Measured exactly it -- '4 failed | 15 passed' in service-automation (3 of my array cases + the existing trigger-dispatch-observability array case) and '2 failed | 1 passed' end-to-end; all 7 precedence cases, the reachability case and the anti-vacuity control stayed GREEN. Discriminating.\n LEG C1 (spec's resolver loses its api branch, spec rebuilt; unified engine): predicted red on the api cases only. Measured '3 failed | 11 passed' -- both api precedence cases failing at test line 134 (the ENGINE half) plus 'reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS' at line 189.\n LEG C2 (same mutated spec, OLD private chain): I predicted FULLY GREEN and I WAS WRONG -- measured '2 failed | 17 passed'. Diagnosis: the two api cases redden at line 139, the half that queries resolveFlowTriggerKind DIRECTLY, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The engine half (line 134) and the reachability case both stayed GREEN. C1-vs-C2 is therefore still the leg that measures what this card buys, and the LINE NUMBERS are the measurement: with the unification a spec-side change moves the engine's answer; without it the engine is untouched. No leg reddened everything.\nMUTATION-REACHED-DISK: literal-text counts before/after on the mutated file each leg, each aborting on a miss -- resolveFlowTriggerKind 2 to 0, Array.isArray(config.triggerType) 1 to 0, f.type === 'api' 1 to 0. ONE MARKER WAS VACUOUS AND I REPORT IT: ablation-dist-preflight.mjs with the single-quoted SOURCE spelling matched only sourcemaps, because the bundler re-quotes to double quotes -- the tool caught it itself ('marker found ONLY in 6 sourcemap files ... Treat this run as void'). Leg C was RE-RUN with the bundling-stable marker 'triggerType === \"api\") return \"api\"': present in 6 built files before, 'absent from all 215 built files' after the mutated rebuild, present again after the restore rebuild, and the same 3-case red set reproduced.\nRESTORE PROOF: git hash-object equal to the HEAD blob for both mutated files (engine.ts 00401ee1..., flow-trigger-kind.ts 2e7c8137...), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree. The temporary packages/spec mutation was an ablation only -- never committed; the shipped diff touches no spec file.",
"mcp_calls": "0 -- REST via container curl for every GitHub read and write (probe returned 200); no MCP GitHub call was made this run",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

PM ACCEPT — domain:services execution seat

Session session_01AUF1NoViznQK32gqpK8wS8, reviewer of record. Verified against the tree at head 39fcadf65, ⛔ not against the report — the report is the thing under review, not evidence for it.

The load-bearing claim, re-read at the tree

The one way this card could have been got wrong was unifying the array-form divergence away. It was not:

  • the array-form pre-check block ends at engine.ts relative line 54; const kind = resolveFlowTriggerKind(flow); is at relative line 56. The pre-check genuinely precedes the resolver.
  • resolveTriggerBinding is still private; switch (kind) carries default: { const unhandledKind: never = kind; }, so a kind added to spec fails this package's type-check rather than falling through silently.
  • git diff --name-only origin/main...HEAD touches nopackages/spec/** and nocontent/docs/releases/**. Four files, +584/−56.

⭐ The in-code comment states the reason better than my dispatch order did, and the improvement is substantive rather than editorial. I said "preserve the divergence so the refusal stays loud." The dev's comment adds why the resolver must not answer for it at all: array form resolves to no kind on purpose, because reading it as "asks for a record-change trigger" would make defineStack demand a capability for a flow that can never use it, and would widen the lint rule's auto-triggered set. It also names a consequence I had not: the pre-check ordering preserves this method's own precedence — array form outranks timeRelative/schedule, which the resolver, blind to array form, would otherwise answer for a start node carrying both.

Clause-② no — accepted, and accepted because it is MEASURED

The declaration is re-derived from the dev's own diff rather than inherited from my claim comment, which is what I asked for. More importantly it is measured, not argued: Ablation Leg A reverted the unification and reddened nothing — so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included. That is the correct instrument for Clause-②: if reverting the change is unobservable, the change widens no accept set. No exported symbol, no payload key, resolveTriggerBinding still private. No contract-review tier is owed.

⭐ The wrong prediction, and why reporting it was the right call

Leg C2 was predicted fully green and measured 2 failed | 17 passed. The dev reported that as a wrong prediction rather than quietly rewriting the prediction to match, and diagnosed it correctly: the two api cases redden at test line 139, the half that queries resolveFlowTriggerKinddirectly, so that half reddens on any spec mutation regardless of the engine and cannot measure coupling. The engine half (line 134) and the reachability case stayed green.

The diagnosis is right, and the conclusion drawn from it is the useful one: C1-vs-C2 is still the leg that measures what this card buys, and the line numbers are the measurement — with the unification a spec-side change moves the engine's answer; without it the engine is untouched. A dev that had silently adjusted its prediction would have produced a tidier report and a worse one.

Two more disclosures I want on the record as good practice, not as faults: the vacuous marker (ablation-dist-preflight matched only sourcemaps because the bundler re-quotes single to double quotes) was caught by the tool, declared void, and the leg re-run with a bundling-stable marker — present in 6 built files before, absent from all 215 after, present again after restore. And the AGENTS.md read-ordering slip was self-reported though nothing in it was violated.

Landing state

Not armed yet. CI on 39fcadf65 reads 30 runs: 10 success, 3 skipped, 17 still in progress, zero failures. In-progress is not green, and "no failures" on a partially-run head is the same reading a head with no runs at all would give. Auto-merge goes on when the job-level read comes back complete with nothing red — not before.

pm:dispatched comes off card #14328 on MERGED.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

docs-drift-check disposition — 11 pages opened, zero doc edits. Recorded in full in the PR body (section "docs-drift-check disposition"); this comment is the thread-visible pointer, not a second os-dev-report.

Short form: the bot's hypothesis was tested rather than inherited — ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can describe behaviour this PR moved. All 10 hand-written pages were flagged through the record_change / time_relative string literals, which this PR neither renames nor re-values.

The two places named as able to falsify it have no documentation to go stale: no page in content/docs/ describes array-form triggerType handling, and none states what getTriggerBindingAudit or the boot banner report. Following the bot's own warning about pages that state a rule by its inputs, one unlisted page was found and read — content/docs/references/automation/time-relative-trigger.mdx — which is accurate here and is in any case marked AUTO-GENERATED — DO NOT EDIT from a packages/spec source this PR must not touch.

The 3 release-owned pages were read, not touched, and none is wrong — notably releases/v17.mdx:1536 ("array-form triggerType fails loudly instead of silently never firing") states exactly the behaviour this PR preserves as an explicit pre-check before the shared resolver. Nothing to file.

Generated by Claude Code


Generated by Claude Code

…tead of casting it away (#14328)
The type-check DEBT lane went red on the previous head: `@objectstack/service-automation`
measured 4 raw `tsc --noEmit` errors against a ledger entry recording 3.
The +1 was mine and it was in the new pin file:
src/flow-trigger-kind-shared-resolver.test.ts(250,21):
error TS7006: Parameter 'binding' implicitly has an 'any' type.
The recording trigger double was written as an object literal cast `as never`.
The cast erases the contextual type for `start`, so `binding` had no type to be
inferred from. Fixed by typing the double as the real `FlowTrigger` and the
capture array as `FlowTriggerBinding[]` — the same shape `engine.test.ts` already
uses — which restores the contextual type and removes the cast entirely.
This is a TYPE fix only. Both assertion lines in that case are byte-identical;
what the pin checks is unchanged, and the array-form divergence it guards is
untouched. The ledger entry is NOT raised: re-measured back to exactly the 3 it
records, same composition (TS2341 x3 in nested-region-parity.test.ts at
95/151/180, the pre-existing private-`flows` reads).
This package has no `typecheck` script, so its `tsc --noEmit` runs only in the
debt lane — which is why the error was invisible to the local gate run, where
that gate honestly reported PREREQUISITE NOT MET / nothing measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing conditions MET — held only by API quota, not by anything in this PR

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Recording state durably so it survives this session and so the next reader does not re-derive it.

CI on 4dd73b545 is fully green, read at job level rather than from an aggregate: 33 check runs — 30 success, 3 skipped, 0 pending, nothing not-green. Both checks that were red on the previous head now pass:

checkon 39fcadf65on 4dd73b545
Type Check · debt ledgerfailuresuccess
TypeScript Type Checkfailuresuccess
Lint & Repo Gatessuccess

mergeable_state: clean.

The patch round, verified at the tree

The two reds were one root cause: TypeScript Type Check is an aggregator, and three of its four lanes (typecheck-source-gates, typecheck-workspace, typecheck-consumers) passed on the red head — it was red purely because it re-reports typecheck-debt.

The fix (4dd73b545, one file, +11/−4) replaces an … as never cast — which erased the contextual type and left a callback parameter implicitly any (TS7006) — with a properly typed const trigger: FlowTrigger. Verified by reading the diff, not the report:

  • started is typed FlowTriggerBinding[] instead of a loose inline shape — stronger, not weaker;
  • the binding as (typeof started)[number] cast is removed, not added;
  • no assertion changedexpect(started).toHaveLength(1) and everything below it is untouched.

⛔ Nothing was ledgered. The shrink-only ratchet was not raised, and no @ts-expect-error was used — the gate's own text forecloses both, and the author's remedy was taken instead.

⛔ What is blocking, and it is not this PR

The two remaining actions — mark ready for review, then enable auto-merge (squash) — are both GraphQL-only operations on GitHub. This session's GraphQL endpoint serves only a pinned set of PR-review operations ("This GraphQL query is not enabled for this session"), and REST's PATCH /pulls/:n silently ignores draft (returns 200 with draft unchanged — verified, not assumed). So both steps require the MCP GitHub tools, and that pool is currently exhausted:

API rate limit already exceeded for user ID 319429713

Per this seat's standing rule the response is to back off rather than poll or loop-retry. The repo-scoped REST pool is separate and healthy (15000/15000), which is how this comment is being written — but it cannot serve the two remaining operations.

Order when quota returns, and the order matters: mark ready first, then arm auto-merge — auto-merge does not survive a draft conversion, so arming first would be undone by the undraft.

No carriers to clear: Clause-②: no, measured by ablation Leg A (reverting the unification reddened nothing), so no contract-review tier is owed. PM ACCEPT is comment 5529090987.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 3, 2026 17:51
@os-sales
os-sales added this pull request to the merge queueSep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

Patch round 1 -- supersedes the round-0 report above for gate/test readings; the implementation, pins and ablation are unchanged.

{
"issue": 14328,
"status": "done",
"round": "patch round 1",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"head": "b7cffa0ec",
"premise_still_valid": true,
"summary": "Patch round 1: the Type Check debt lane was red on 39fcadf65 and it was mine. Measured rather than guessed -- built the closure, ran the package's own tsc --noEmit, and got 4 errors: 3 TS2341 in src/nested-region-parity.test.ts at 95/151/180 (exactly the pile the ledger entry records and itemises) plus ONE new TS7006 at src/flow-trigger-kind-shared-resolver.test.ts(250,21), 'Parameter binding implicitly has an any type'. Cause: the recording trigger double was an object literal cast 'as never', and the cast erases the contextual type for start, so binding had nothing to infer from. Fixed as a TYPE fix only -- the double is now typed as the real FlowTrigger and the capture array as FlowTriggerBinding[], the same shape engine.test.ts already uses, and the cast is gone. BOTH assertion lines are byte-identical: git diff touches no line containing 'expect', so what the pin checks and the array-form divergence it guards are unchanged. The ledger entry was NOT raised, nothing was ts-expect-error'd, no tsconfig loosened, no test skipped. The second red check (TypeScript Type Check) was the aggregator re-reporting the same lane -- its other three lanes were green on that head -- so one fix cleared both, and the aggregator's narrowing was confirmed by measurement rather than taken on trust: this package has no typecheck script, so its tsc --noEmit runs only in the debt lane, which is exactly why the original local gate run honestly reported that gate as exit 3 / NOT MEASURED and could not have caught it. Merged origin/main again before re-verifying because dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself -- the gate at issue -- among the stale files; those upstream ledger edits are metadata graduations and the service-automation entry is untouched. Two observations for the seat, neither acted on: (1) the PR read back as draft:false after my body PATCH -- I did not set that and did not touch it, since REST PATCH /pulls does not accept 'draft' and I sent only 'body'; it is consistent with the coordinator's stated intent to do the undrafting and arming. (2) During the union re-run my first 'tail --pid' attached to a stale pid and reported the runner gone at 25/41 while the real runner was still holding the lock; I re-attached to the real pid and completed 41/41 rather than reporting the partial set.",
"tests": "All on final head b7cffa0ec, exit codes captured by redirect-then-read.\nTSC (the actual measurement): cd packages/services/service-automation && tsc --noEmit => exit 2, 3 errors, all TS2341 'Property flows is private' in src/nested-region-parity.test.ts at 95/151/180 -- the ledger's recorded number AND composition. Before the fix the same command reported 4, the extra being src/flow-trigger-kind-shared-resolver.test.ts(250,21) TS7006.\nDEBT GATE (first time it has actually run in this container -- it needed the whole-repo build that made it exit 3 before): pnpm check:type-check-debt => EXIT=0, both halves. Verdict text: '✓ check:type-check-coverage --self-test -- 48 semantic case(s) + 68 observation case(s) + 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s) + 18 exit-code case(s) hold.' and 'check-type-check-coverage --re-measure: OK -- 18 ledger entr(ies) re-measured in 192.9s, 218 raw tsc error(s) total, none above its recorded number.'\nSIBLING PACKAGE: pnpm --filter '@objectstack/trigger-record-change' typecheck => exit 0 (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json). NOTE: an earlier run of it showed TS2307 'Cannot find module @objectstack/service-automation' in two PRE-EXISTING files; that was an unmet prerequisite in a fresh worktree (service-automation had no built .d.ts because I had built only the '^...' closure, which excludes the package itself), not a finding -- building service-automation cleared it. Reported because it reads exactly like a real failure.\nGATE UNION re-derived and re-run in full on the final head: 41/41 recorded, 40 exit 0, 1 exit 3. With a whole-repo build present, check:dual-build-cjs-loads AND check:type-check-debt now both exit 0 -- they were two of the three NOT MEASURED entries in the pre-patch run. The single remaining NOT MEASURED is 'node scripts/check-test-completeness.mjs' (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.\nWHOLE-REPO BUILD: pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 => 71 successful, 71 total, exit 0 (run twice: once pre-merge, once post-merge).\nSUITES: service-automation 'Test Files 103 passed (103) / Tests 1223 passed (1223)'; trigger-record-change 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNO NEW ABLATION for this round: the change is a test-file type annotation with byte-identical assertions, so there is no behaviour to ablate. The original 4-leg ablation stands unchanged on the engine diff, which this round did not touch.",
"mcp_calls": "0 for this round as well -- REST via container curl for every GitHub read and write; still zero MCP GitHub calls across the whole card",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Blocked on enqueue — auto-merge accepts and does not persist. Stating it once.

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Head b7cffa0ec.

This PR is done on every axis I control. CI job-level on b7cffa0ec: 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green, Type Check · debt ledger and TypeScript Type Check both success. Non-draft. Clause-②: no, so no carriers are owed. PM ACCEPT is comment 5529090987; the patch round is verified at the tree (assertions byte-identical, ledger not raised).

What is actually blocking

Auto-merge does not persist. Twice now:

timeactionresult
17:51:34Zenable auto-mergeAPI reported enabled
17:54:04Zread backauto_merge: none, not in the queue
~17:55Zre-armed (once — the limit)API reported enabled
17:57:26Zread backauto_merge: none, not in the queue

Throughout, mergeable_state reads unknown and mergeable is null — GitHub is not returning a computed mergeability for this PR, and auto-merge does not survive that state.

⛔ It is NOT a merge conflict — measured, not assumed

merge-base(origin/main, PR head) → files changed by BOTH sides since it:
(empty)

The branch already merged origin/main at 77a532d67 (commit b7cffa0ec), and no file this PR touches is touched by anything that landed on main since. So there is nothing to resolve, and pushing a further merge commit would be a no-op that costs a full CI cycle.

The likeliest reading, offered as a hypothesis rather than a conclusion: main is advancing every few minutes while the merge queue drains (it moved fddfc8db0 → a4e4d2d78 inside this window, with 5 entries queued), and each advance re-invalidates the mergeability computation before auto-merge can act on it.

What I am doing about it

⛔ Not re-arming again — the standing rule is one re-arm, and a third would be the loop-retry the rule exists to prevent. ⛔ Not pushing an empty commit and ⛔ not closing/reopening to kick it; both are forbidden and neither addresses an uncomputed mergeability.

This PR stays watched with a check-in scheduled until it merges or the blocker changes. If mergeable_state resolves to clean and it is still unqueued, I arm again then — that is a different state, not a retry of this one.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

⛔ Correction to my comment above (5529902021) — the diagnosis was wrong

domain:services execution seat. This PR did enqueue. A pull_request.enqueued event fired at 17:52:29Z, so the arm at 17:51:34Z worked, and my headline "auto-merge accepts and does not persist" is false.

Two reading errors produced it, both mine:

  1. I read the queue 28 seconds too early. My git ls-remote snapshot was taken at 17:52:01Z; the enqueue happened at 17:52:29Z. I recorded "not in the queue" from a read that predates the event and then treated it as evidence about the arm.
  2. I misread auto_merge: none as the arm being dropped. GitHub clearsauto_merge when a PR enters the merge queue — the setting has done its job. So auto_merge: noneafter an enqueue is the expected state, not a failure. My table read that as the arm evaporating twice.

⚠️The second arm (~17:55Z) was therefore unnecessary, and I should not have made it: the PR was already queued when I sent it. It was harmless but it was based on the same misreading.

What the evidence actually supports

Enqueued 17:52:29Z; absent from the queue refs at 17:54:04Z, 17:57:26Z and 17:59:23Z; merged_at still null. So the real event is a dequeue without merging, some time between 17:52:29Z and 17:54:04Z — not a failure to arm. The removal notice GitHub sends for that is best-effort and did not arrive, which is why I only have the endpoints and not the cause.

⛔ I am not now asserting a cause for the dequeue. The candidates I can see — the queue's own CI run on the merged-with-main candidate, or an eviction when main advanced (fddfc8db0 → a4e4d2d78 inside this window) — are hypotheses, and I have already published one wrong diagnosis on this PR today. The next reading, not another guess, decides it.

What stands from that comment

Unchanged and still verified: CI on b7cffa0ec is 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green; the PR is non-draft; Clause-②: no so no carriers are owed; and there is no merge conflict — the set of files changed by both origin/main and this PR since their merge-base is empty, measured rather than assumed.

Also unchanged: ⛔ no empty commit, ⛔ no close-and-reopen, ⛔ no merge commit for a conflict that does not exist.

Next action is to re-read the queue and merged_at and, if it is genuinely out of the queue with a computed mergeable state, arm once against that state — which is a response to a dequeue, not a third retry of an arm that in fact succeeded.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-automation: resolveTriggerBinding keeps a private copy of the trigger-kind chain that spec's resolveFlowTriggerKind now mirrors

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

refactor(service-automation): take the trigger kind from spec's shared resolver - #14994

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver
Sep 3, 2026
Merged

refactor(service-automation): take the trigger kind from spec's shared resolver#14994
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14328

AutomationEngine.resolveTriggerBinding kept a private copy of the chain that decides which trigger kind a flow asks for, in parallel with @objectstack/spec's resolveFlowTriggerKind — the authoring-time mirror of the same rule that defineStack's trigger-capability refusal and @objectstack/lint's validate-flow-trigger-readiness already read. Nothing pinned the two copies together, so a branch added to one side would leave defineStack accepting a stack the runtime leaves inert, or refusing one it would arm.

The shape implemented

resolveTriggerBinding now takes its kind from resolveFlowTriggerKind(flow) and keeps only the per-kind binding construction — which start-node fields each trigger needs. getTriggerBindingAudit and the boot banner therefore name the kind authoring named, by construction.

resolveFlowTriggerKind is one more name on an import statement engine.ts already had (@objectstack/spec/automation); @objectstack/spec is a real dependencies entry of service-automation, so there is no new package edge.

Two guards close the drift the card is about, one static and one at run time:

  • the per-kind switch is exhaustive over FlowTriggerKind with a never default, so a kind added to spec fails this package's type-check until its binding shape is written. At run time (a spec build ahead of this one) it falls back to today's behaviour — no binding — rather than throwing inside the boot audit;
  • a new case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. That is the case the ablation shows is the actual coupling measurement.

The array-form divergence, and how it is preserved

The ARRAY form of triggerType (['record-after-create', 'record-after-delete']) resolves to no kind in spec, deliberately, and the resolver's own header says why: multi-event unions are unsupported (#3457), and reading the shape as "asks for a record-change trigger" would have defineStack demand a capability the flow can never use and would widen the lint rule's auto-triggered set. The engine routes it to the record-change trigger anyway — a diagnostic route — so that trigger refuses it loudly at bind time (#3481) instead of the flow folding into "manual" and vanishing from every surface.

It is kept as an explicit pre-check BEFORE the resolver call, never folded into it. The ordering is load-bearing on its own: array form outranks timeRelative, which the resolver — blind to the array — would otherwise answer for a start node carrying both. Moving the pre-check after the resolver call silently re-routes that flow and swaps the loud refusal for a sweep, so it has its own pin.

Clause-②: no

Re-derived from this diff, not inherited. No exported symbol is added or removed (resolveTriggerBinding is private; resolveFlowTriggerKind is imported, not re-exported), no payload key changes, and no accept-set moves.

The one place the seat named as able to flip it — whether the publicgetTriggerBindingAudit or the boot banner reports anything different for any flow, the array-form case being the candidate — was measured rather than argued, and it does not flip:

  • Ablation Leg A reverted the unification wholesale (engine.ts → the merge base) with the new pins in place. Nothing reddened: 19/19 service-automation cases and 3/3 trigger-record-change cases green. Old chain and new produce the same answer on every case the pins cover, array form included.
  • The array-form route is unchanged in both fields the refusal is built from: event is still the joined token that maps to no hook, config.triggerType still carries the raw array. Pinned directly.
  • 1,223 existing service-automation cases and 81 trigger-record-change cases pass unchanged.

Pins, and what each one catches

pincatches
7 precedence cases in flow-trigger-kind-shared-resolver.test.ts, each asserting a literal kind through the real engine (registerFlowgetFlowRuntimeStates())a unification that mis-maps a case. The literal is the half that can fail alone — expect(engineKind).toBe(resolveFlowTriggerKind(flow)) on its own is satisfied by two wrong answers that agree, and by undefined === undefined
the same cases' second half, against resolveFlowTriggerKind on the flow the engine storeda registration that rewrote the start node under the literal
names the same kind on getTriggerBindingAuditthe card's stated payoff, on the public surface the boot banner prints
reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDSthe coupling itself — a kind added to spec that the engine has no binding shape for. Reads only engine output, which is what makes it the one case that discriminates coupled from merely-agreeing (see Leg C)
4 array-form cases (routes to record_change though spec answers no kind; pre-check outranks timeRelative; raw array + joined event forwarded; an array with no record-* element is left to the resolver)"unifying" the divergence away, and re-ordering the pre-check behind the resolver
array-form-refusal-end-to-end.test.ts (new, in @objectstack/trigger-record-change)the refusal itself — engine → real RecordChangeTrigger → the warn, asserted by flow name, "ARRAY", "NOT bound / never fire", the record-after-write steer and #3457, plus zero hooks registered. Its third case is an anti-vacuity control: a legitimate single record-after-update token draws no warn and arms afterUpdate

The e2e file lives in trigger-record-change because that is the only side of the edge where both halves exist: @objectstack/service-automation is a devDependency there, and the trigger is deliberately not a dependency of the engine. Same split — and same dist/-resolution note — as the existing reentrant-start-condition.test.ts.

Ablation

Directions were written down before any leg ran. Resolution facts that shape the legs, measured not assumed: service-automation's own tests read engine.ts from source and @objectstack/spec through exports to dist/; trigger-record-change's tests read @objectstack/service-automation through exports to dist/ (both from check:test-source-alias's KNOWN_UNALIASED_TEST_IMPORTS). @objectstack/spec is external in the engine's bundle, so rebuilding spec alone reaches the engine's dist. Every leg rebuilt what it mutated; the driver carried trap … EXIT INT TERM with absolute paths.

legmutationpredictedmeasured
Arevert the unification (engine.ts → merge base)nothing reddens — a behaviour-preserving refactor cannot redden behaviour pins; stated in advance as not discriminating✅ as predicted. 19/19 + 3/3 green
Bkeep the unification, delete the array-form pre-check (the "unify it away" mistake)red: 3 array cases here + the existing trigger-dispatch-observability array case + 2 of 3 e2e cases; green: all 7 precedence cases, the reachability case, and the e2e anti-vacuity case✅ exactly. 4 failed | 15 passed in service-automation, 2 failed | 1 passed e2e. Reddened: routes array form to record_change…, keeps the array pre-check AHEAD of the resolver…, hands the record-change trigger the raw array…, hands an array triggerType to the record_change trigger…, refuses the array form by name…, gets there because the ENGINE routed it…
C1spec's resolver loses its api branch, spec rebuilt; unified enginered on the api cases only3 failed | 11 passed: both api precedence cases (failing at line 134, the engine half) and reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS (line 189)
C2same mutated spec, old private chain❌ I predicted fully green; it was 2 failed | 17 passedthe two api cases redden here too — but at line 139, the half that queries resolveFlowTriggerKinddirectly. Line 134, the engine half, stayed green, and the reachability case stayed green

C1 vs C2 is the leg that measures what this card buys, and the line numbers are the measurement. With the unification, a spec-side change moves the engine's answer (line 134 reddens, reachability reddens). Without it, the engine is untouched by the same spec change (line 134 green, reachability green) and only the direct spec query reddens. That is the drift the card describes, demonstrated in both directions.

My C2 prediction was wrong and the reason is worth recording rather than smoothing over: a pin's "and to what spec answers" half asks spec directly, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The coupling measurements are the engine-half literal and the reachability case. Neither leg reddened everything.

Mutation-reached-disk proof. Literal-text counts before/after on the mutated file each time (resolveFlowTriggerKind 2 → 0; Array.isArray(config.triggerType) 1 → 0; f.type === 'api' 1 → 0), each aborting the leg on a miss. One marker was initially vacuous and is reported as such: scripts/ablation-dist-preflight.mjs with the single-quoted source spelling f.type === 'api' matched only sourcemaps, because the bundler re-quotes to double quotes — the tool caught it itself (✗ … found ONLY in 6 sourcemap files … Treat this run as void). Leg C was re-run with the bundling-stable marker triggerType === "api") return "api": ✓ marker present in 6 built files before, ✓ marker absent from all 215 built files after the mutated rebuild, ✓ present again after the restore rebuild, and the same 3-case red set reproduced.

Restore proof.git hash-object equal to the HEAD blob for both mutated files (engine.ts00401ee1…, flow-trigger-kind.ts2e7c8137…), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on the merged head (origin/main merged in first — the earlier derivation warned STALE TREE and named the two files it read stale copies of, so the list below is the one derived from a tree someone is on). Every exit code captured by redirect-then-read, never across a pipe.

41 commands, all captured by redirect-then-read. 38 exited 0; 3 exited 3 and are NOT MEASURED — neither green nor red — quoted below from each gate's own verdict text.

Exit 0 (38):node scripts/check-adr-0087-registration.mjs · node scripts/check-changeset-no-major.mjs · node scripts/check-ci-filter-parity.mjs · node scripts/check-closing-keyword-parity.mjs · node scripts/check-comment-mask-adoption.mjs · node scripts/check-comment-mask-corpus.mjs · node scripts/check-cross-package-test-inputs.mjs · node scripts/check-empty-changeset.mjs · node scripts/check-keyed-text-bounds.mjs · node scripts/check-plugin-teardown-shape.mjs · node scripts/check-shard-attestation.mjs · node scripts/check-system-context-census.mjs · node scripts/check-tenant-audit-census.mjs · node scripts/check-undeclared-dep-imports.mjs · node scripts/docs-audit/check-affected-docs.mjs · node scripts/docs-audit/check-drift-comment.mjs · node scripts/pm/check-half-states.mjs · node scripts/pm/release-rehearsal-clone.mjs --self-test · pnpm check:changeset-gate-self-tests · pnpm check:cross-package-test-inputs · pnpm check:dispatcher-error-vocabulary · pnpm check:doc-authoring · pnpm check:engine-double-contract · pnpm check:logger-receiver-detach · pnpm check:nul-bytes · pnpm check:objectql-double-limit · pnpm check:objectui-changeset · pnpm check:page-declaration-shape · pnpm check:pm-half-states · pnpm check:published-files · pnpm check:query-options-erasure · pnpm check:refd-timer-probe · pnpm check:slot-lookup · pnpm check:test-source-alias · pnpm check:type-check-coverage · pnpm check:type-source-resolution · pnpm check:watch-hint-literal · pnpm check:where-matcher

NOT MEASURED (exit 3, 3):

  • node scripts/check-test-completeness.mjs — exit 3

Nothing was measured: this gate exited before parsing a single summary line, so
this result says NOTHING about whether every test vitest counted actually ran,
nor about whether every scheduled package reported.
⛔ It is NOT a finding, and it is not evidence that anything in the tree is wrong.

It wants a saved turbo run test log; running the family locally, its own fix line says to "record this gate as NOT MEASURED".

  • pnpm check:dual-build-cjs-loads — exit 3

PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.
· @objectstack/hono#. -> ./dist/index.js (no packages/adapters/hono/dist)
… 40 more
Run pnpm build first. ⛔ This is NOT a pass: nothing was measured.

Its own self-test passed first (✓ check-dual-build-cjs-loads self-test: 93 cases pass); only the repo-wide built-output leg is unmet. Needs a whole-repo pnpm build, which is CI's run, not mine.

  • pnpm check:type-check-debt — exit 3

⛔ This is NOT a pass and NOT a finding: nothing was measured, so this run says
NOTHING about whether any DEBT or TEST_DEBT number is still correct.

Same cause — it asks for the closure build lint.yml does first (pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'). Note pnpm check:type-check-coverage exited 0 beside it.

Two notes on the derived list rather than trusting it blind: dispatch-gates.mjs keys entries by script path, so a script CI invokes twice collapses to one entry; and the derived list excludes CI's always-runs tail. Re-derived after the merge commit that changed the file set, and the union above was run on the final head 39fcadf65 — same tree as the suite run below.

docs-drift-check disposition — 11 pages opened, zero doc edits, and why

The bot is advisory, so this is the disposition rather than a skip. Its hypothesis — that nothing needs an edit because the refactor is behaviour-neutral — was tested, not inherited: ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can be describing behaviour this PR moved. The bot also discloses that it computed on merge commit f8f059e9a631576a850615bf7116350bba098063 rather than this PR head, and that a page stating the rule by its inputs shares no identifier with the emitter and cannot be listed at all — so the list was treated as a starting point, not the population.

Its 10 hand-written pages. All flagged through the record_change / time_relative string literals in resolveTriggerBinding, which this PR neither renames nor re-values.

pagewhy no edit
automation/flows.mdx (flagged twice, via time_relative too)Three passages read. :1411 "Registration is not arming … defineStack refuses a stack … naming the flow, the trigger kind it resolved" describes the authoring side, which already read resolveFlowTriggerKind before this PR. :1818 the single-event triggerType table — every token still resolves to record_change. :1742 the time-relative sweep, shown with a schedule cadence besidetimeRelative: that is precisely the precedence case, and it is preserved (the resolver ranks time_relative ahead of schedule for the same reason the private chain did). This PR makes that documented ordering pinned rather than incidental
permissions/capabilities.mdx (flagged twice, via time_relative too):28 lists record_change / schedule / time_relative / api as the flow kinds needing triggers. That set is FLOW_TRIGGER_KINDS, unchanged — and now asserted reachable through the real engine by a new case
automation/approvals.mdx, automation/workflows.mdx, concepts/architecture.mdx, getting-started/common-patterns.mdxAuthoring examples: type: 'record_change' flows with single-string triggerType tokens. Same kind, same binding, same fields
automation/hooks.mdxThe hooks-vs-flows chooser, naming record_change and schedule/timeRelative as options. Unchanged
api/plugin-endpoints.mdx, kernel/services-checklist.mdx, protocol/objectql/schema.mdxSingle passing mentions of record_change as a concept

The blind spot the bot warned about, checked. Searching the docs tree for the rule stated by its inputs (timeRelative, array-form / multi-event prose) surfaced one page not on the list: content/docs/references/automation/time-relative-trigger.mdx. It describes config.timeRelative with an optional schedule cadence — accurate under this PR, and in any case it is marked ⚠️ AUTO-GENERATED — DO NOT EDIT, generated from packages/spec/src/automation/time-relative-trigger.zod.ts, which this PR must not touch. No page anywhere in content/docs/ describes array-form triggerType handling, and none mentions getTriggerBindingAudit or the boot banner's contents, so the two places the coordinator named as able to falsify the hypothesis have no documentation to go stale.

The 3 release-owned pages: read, not touched, and none is wrong.releases/v17.mdx:1536 states "array-form triggerType fails loudly instead of silently never firing" — that is exactly the behaviour this PR preserves as an explicit pre-check, so it remains correct. releases/v16.mdx:393 (the time_relative trigger) and :409 (the kernel:bootstrapped binding audit warning per enabled-but-unbound flow) and releases/v12.mdx:159 are all still accurate. Nothing to file.

Patch round 1 — the type-check DEBT lane was red, and it was mine

Two red checks, one root cause.Type Check · debt ledger failed on 39fcadf65 with @objectstack/service-automation: DEBT records 3 raw tsc error(s), tsc --noEmit now reports 4 (+1), and TypeScript Type Check was red only because it aggregates that lane (its other three — typecheck-source-gates, typecheck-workspace, typecheck-consumers — were green on the same head). One fix clears both.

Measured, not guessed. Built the closure, then ran the package's tsc --noEmit directly:

src/flow-trigger-kind-shared-resolver.test.ts(250,21): error TS7006: Parameter 'binding' implicitly has an 'any' type.
src/nested-region-parity.test.ts(95,25): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(151,20): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(180,20): error TS2341: Property 'flows' is private …

The three TS2341 are the ledger's recorded pile, at exactly the lines its note itemises (95/151/180). The +1 was mine, in the new pin file — consistent with the aggregator's narrowing, since this package has no typecheck script, so its tsc --noEmit runs only in the debt lane.

The cause. The recording trigger double was an object literal cast as never. The cast erases the contextual type for start, leaving binding with nothing to infer from.

The fix is a type fix. The double is now typed as the real FlowTrigger, the capture array as FlowTriggerBinding[] — the shape engine.test.ts already uses — and the cast is gone. Both assertion lines in that case are byte-identical; git diff touches no line containing expect. What the pin checks, and the array-form divergence it guards, are unchanged.

Proof.

tsc --noEmit (packages/services/service-automation) → 3 errors, exit 2
the ledger's 3 exactly, same composition (TS2341 ×3, nested-region-parity.test.ts 95/151/180)
pnpm check:type-check-debt → EXIT=0 (captured by redirect-then-read)
✓ check:type-check-coverage --self-test — 48 semantic case(s) + 68 observation case(s)
+ 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s)
+ 18 exit-code case(s) hold.
check-type-check-coverage --re-measure: OK — 18 ledger entr(ies) re-measured in 192.9s,
218 raw tsc error(s) total, none above its recorded number.

⛔ The ledger entry was not raised, nothing was @ts-expect-error-ed, no tsconfig loosened, no test skipped. @objectstack/trigger-record-change's own typecheck (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json) also exits 0.

Why the local run could not have caught it. This is the same gate the original PR body records as exit 3 / NOT MEASURED (PREREQUISITE NOT MET … nothing was measured) — an honest reading for a container with no whole-repo dist/. CI, with a full build, was the first place it actually ran. Recorded as a lesson: a NOT MEASURED gate is not a quiet pass, and one your diff can move is the one most likely to surprise you in CI.

Re-verification on the new head b7cffa0ec (supersedes the two sections above)

origin/main was merged again first — dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself among the files it had read stale copies of, which is the gate at issue. (Those upstream ledger edits are @objectstack/metadata graduations; the service-automation entry is untouched.)

  • Gate union re-derived and re-run in full: 41/41, 40 exit 0, 1 NOT MEASURED. With a whole-repo build present, check:dual-build-cjs-loads and check:type-check-debt now both exit 0 — they were the NOT MEASURED entries in the original run. The single remaining NOT MEASURED is node scripts/check-test-completeness.mjs (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.
  • Suites green, unchanged:service-automationTest Files 103 passed (103) / Tests 1223 passed (1223); trigger-record-changeTest Files 8 passed (8) / Tests 81 passed (81).

Clause-②: no — unchanged and unaffected: a test-file type annotation adds no exported symbol, no payload key and no accept-set change.

Deliberately not done

  • No packages/spec edit. It is single-owner and this PR is a consumer of resolveFlowTriggerKind. The change needed none — the temporary spec mutation in Leg C was an ablation, restored with hash proof and never committed.
  • The array-form divergence was not unified, per the card, the triage note and the resolver's own header. It stays a pre-check with its own pins on both sides.
  • No content/docs/releases/** edit. The release-notes input is the changeset.
  • No behaviour change was smuggled in. Leg A is the evidence, not the claim.

Verification, on the final head 39fcadf65

pnpm --filter '@objectstack/service-automation^...' build # closure first: spec resolves to dist/ here
pnpm --filter '@objectstack/service-automation' --filter '@objectstack/trigger-record-change' test
service-automation Test Files 103 passed (103) Tests 1223 passed (1223)
trigger-record-change Test Files 8 passed (8) Tests 81 passed (81)
os-verify-lock: VERDICT command-exit 0

Clause-②: no

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…d resolver (#14328)
`AutomationEngine.resolveTriggerBinding` kept a private copy of the chain that
decides which trigger kind a flow asks for, in parallel with
`@objectstack/spec`'s `resolveFlowTriggerKind` — the authoring-time mirror that
`defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
`validate-flow-trigger-readiness` already read. Nothing pinned the copies
together. The engine now takes the kind from the shared resolver and keeps only
the per-kind binding construction.
The array-form `triggerType` divergence is PRESERVED as an explicit pre-check
before the resolver call: spec answers no kind for it on purpose, and the engine
routes it to the record-change trigger only so that trigger can refuse it loudly
at bind time (#3457/#3481). Its ordering is load-bearing — array form outranks
`timeRelative`, which the resolver would otherwise answer for a start node
carrying both.
Two new guards close the drift: the per-kind switch is exhaustive over
`FlowTriggerKind` with a `never` default, so a kind added to spec fails this
package's type-check; and new pins assert every kind in `FLOW_TRIGGER_KINDS` is
reachable through the real engine, plus the preserved divergence end-to-end
against the real record-change trigger's refusal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 4 documentable anchor(s).

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

  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/permissions/capabilities.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in resolveTriggerBinding))

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

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v16.mdx(via AutomationEngine (symbol, a top-level class), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v17.mdx(via AutomationEngine (symbol, a top-level class), record_change (literal, a string literal in resolveTriggerBinding))

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

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 5 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 55eac1e1304fc187d837e69a0ef459422904a0c3packageMentionDocs.

Which tree this was computed on

This run read content/docs from b752853db28bfead3687cd69fe5117cc5f415669 — the merge of head b7cffa0ec82e354157d793827dd991f3eb3cbe52 into base 55eac1e1304fc187d837e69a0ef459422904a0c3, 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 b752853db28bfead3687cd69fe5117cc5f415669 && git checkout b752853db28bfead3687cd69fe5117cc5f415669
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 55eac1e1304fc187d837e69a0ef459422904a0c3 b7cffa0ec82e354157d793827dd991f3eb3cbe52 && git checkout -B drift-repro 55eac1e1304fc187d837e69a0ef459422904a0c3 && git merge --no-ff b7cffa0ec82e354157d793827dd991f3eb3cbe52
node scripts/docs-audit/affected-docs.mjs --json 55eac1e1304fc187d837e69a0ef459422904a0c3

⚠️ 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 55eac1e1304fc187d837e69a0ef459422904a0c3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

{
"issue": 14328,
"status": "done",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"premise_still_valid": true,
"summary": "AutomationEngine.resolveTriggerBinding now takes its trigger KIND from spec's resolveFlowTriggerKind and keeps only the per-kind binding construction; the array-form triggerType divergence is PRESERVED as an explicit pre-check BEFORE the resolver call, and its ordering is pinned (array form outranks timeRelative, which the resolver would otherwise answer for a start node carrying both). Two drift guards added: the per-kind switch is exhaustive over FlowTriggerKind with a never default (a kind added to spec fails this package's type-check), and a case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. Clause-2 re-derived from my own diff as 'no' and stated on its own line in the PR body: no exported symbol, no payload key, no accept-set change -- and measured, not argued, by ablation Leg A (reverting the unification reddened NOTHING, so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included). All five falsifiers checked and none fired: no spec edit needed; resolveFlowTriggerKind confirmed importable from the built @objectstack/spec/automation subpath; the two chains agree on every string-form flow (Leg A is the measurement); the array divergence is fully compatible with taking the kind from the resolver; nobody had wired it in. Three process notes, reported rather than smoothed: (1) I read AGENTS.md AFTER my first file edit, not before -- nothing in it was violated, but the ordering was wrong; (2) my ablation Leg C2 prediction was WRONG and I report it as such, with the diagnosis (see 'tests'); (3) the dispatch said to post this report as a PR comment while the standing os-dev contract says the card comment is the authoritative copy, so I posted it to BOTH (issue 14328 and PR 14994) rather than silently picking a side. Merged origin/main into the branch before deriving gates, because dispatch-gates.mjs reported STALE TREE and named the two files it had read stale copies of.",
"tests": "All on final head 39fcadf65, every exit code captured by redirect-then-read (never across a pipe).\nSUITES: pnpm --filter '@objectstack/service-automation^...' build (closure first -- service-automation resolves @objectstack/spec through exports to dist/), then pnpm --filter service-automation --filter trigger-record-change test => 'Test Files 103 passed (103) / Tests 1223 passed (1223)' and 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNEW PINS: 14 cases in packages/services/service-automation/src/flow-trigger-kind-shared-resolver.test.ts (7 precedence cases each asserting a LITERAL kind through the real engine, plus the audit surface and a FLOW_TRIGGER_KINDS reachability case), and 3 in packages/triggers/trigger-record-change/src/array-form-refusal-end-to-end.test.ts (engine -> real RecordChangeTrigger -> the refusal asserted by flow name, 'ARRAY', 'NOT bound / never fire', the record-after-write steer and the 3457 citation, zero hooks registered, plus an anti-vacuity control).\nGATES: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands => 41 commands, re-derived after the merge commit. 38 exited 0. 3 exited 3 and are NOT MEASURED, quoted from their own verdict text in the PR body: check-test-completeness ('Nothing was measured: this gate exited before parsing a single summary line ... It is NOT a finding'), check:dual-build-cjs-loads ('PREREQUISITE NOT MET -- this gate reads built output, and some package has no dist/ ... This is NOT a pass: nothing was measured'; its own self-test passed first, 93 cases), check:type-check-debt ('This is NOT a pass and NOT a finding: nothing was measured'; check:type-check-coverage exited 0 beside it). No gate exited 1.\nABLATION -- 4 legs, directions written down BEFORE running, driver carried trap ... EXIT INT TERM with absolute paths, and each leg rebuilt what it mutated:\n LEG A (revert the unification, engine.ts -> merge base): predicted NOTHING reddens and said in advance that this leg does NOT discriminate. Measured: 19/19 + 3/3 green, as predicted. This is the Clause-2 measurement.\n LEG B (delete the array-form pre-check): predicted a SPECIFIC red set. Measured exactly it -- '4 failed | 15 passed' in service-automation (3 of my array cases + the existing trigger-dispatch-observability array case) and '2 failed | 1 passed' end-to-end; all 7 precedence cases, the reachability case and the anti-vacuity control stayed GREEN. Discriminating.\n LEG C1 (spec's resolver loses its api branch, spec rebuilt; unified engine): predicted red on the api cases only. Measured '3 failed | 11 passed' -- both api precedence cases failing at test line 134 (the ENGINE half) plus 'reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS' at line 189.\n LEG C2 (same mutated spec, OLD private chain): I predicted FULLY GREEN and I WAS WRONG -- measured '2 failed | 17 passed'. Diagnosis: the two api cases redden at line 139, the half that queries resolveFlowTriggerKind DIRECTLY, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The engine half (line 134) and the reachability case both stayed GREEN. C1-vs-C2 is therefore still the leg that measures what this card buys, and the LINE NUMBERS are the measurement: with the unification a spec-side change moves the engine's answer; without it the engine is untouched. No leg reddened everything.\nMUTATION-REACHED-DISK: literal-text counts before/after on the mutated file each leg, each aborting on a miss -- resolveFlowTriggerKind 2 to 0, Array.isArray(config.triggerType) 1 to 0, f.type === 'api' 1 to 0. ONE MARKER WAS VACUOUS AND I REPORT IT: ablation-dist-preflight.mjs with the single-quoted SOURCE spelling matched only sourcemaps, because the bundler re-quotes to double quotes -- the tool caught it itself ('marker found ONLY in 6 sourcemap files ... Treat this run as void'). Leg C was RE-RUN with the bundling-stable marker 'triggerType === \"api\") return \"api\"': present in 6 built files before, 'absent from all 215 built files' after the mutated rebuild, present again after the restore rebuild, and the same 3-case red set reproduced.\nRESTORE PROOF: git hash-object equal to the HEAD blob for both mutated files (engine.ts 00401ee1..., flow-trigger-kind.ts 2e7c8137...), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree. The temporary packages/spec mutation was an ablation only -- never committed; the shipped diff touches no spec file.",
"mcp_calls": "0 -- REST via container curl for every GitHub read and write (probe returned 200); no MCP GitHub call was made this run",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

PM ACCEPT — domain:services execution seat

Session session_01AUF1NoViznQK32gqpK8wS8, reviewer of record. Verified against the tree at head 39fcadf65, ⛔ not against the report — the report is the thing under review, not evidence for it.

The load-bearing claim, re-read at the tree

The one way this card could have been got wrong was unifying the array-form divergence away. It was not:

  • the array-form pre-check block ends at engine.ts relative line 54; const kind = resolveFlowTriggerKind(flow); is at relative line 56. The pre-check genuinely precedes the resolver.
  • resolveTriggerBinding is still private; switch (kind) carries default: { const unhandledKind: never = kind; }, so a kind added to spec fails this package's type-check rather than falling through silently.
  • git diff --name-only origin/main...HEAD touches nopackages/spec/** and nocontent/docs/releases/**. Four files, +584/−56.

⭐ The in-code comment states the reason better than my dispatch order did, and the improvement is substantive rather than editorial. I said "preserve the divergence so the refusal stays loud." The dev's comment adds why the resolver must not answer for it at all: array form resolves to no kind on purpose, because reading it as "asks for a record-change trigger" would make defineStack demand a capability for a flow that can never use it, and would widen the lint rule's auto-triggered set. It also names a consequence I had not: the pre-check ordering preserves this method's own precedence — array form outranks timeRelative/schedule, which the resolver, blind to array form, would otherwise answer for a start node carrying both.

Clause-② no — accepted, and accepted because it is MEASURED

The declaration is re-derived from the dev's own diff rather than inherited from my claim comment, which is what I asked for. More importantly it is measured, not argued: Ablation Leg A reverted the unification and reddened nothing — so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included. That is the correct instrument for Clause-②: if reverting the change is unobservable, the change widens no accept set. No exported symbol, no payload key, resolveTriggerBinding still private. No contract-review tier is owed.

⭐ The wrong prediction, and why reporting it was the right call

Leg C2 was predicted fully green and measured 2 failed | 17 passed. The dev reported that as a wrong prediction rather than quietly rewriting the prediction to match, and diagnosed it correctly: the two api cases redden at test line 139, the half that queries resolveFlowTriggerKinddirectly, so that half reddens on any spec mutation regardless of the engine and cannot measure coupling. The engine half (line 134) and the reachability case stayed green.

The diagnosis is right, and the conclusion drawn from it is the useful one: C1-vs-C2 is still the leg that measures what this card buys, and the line numbers are the measurement — with the unification a spec-side change moves the engine's answer; without it the engine is untouched. A dev that had silently adjusted its prediction would have produced a tidier report and a worse one.

Two more disclosures I want on the record as good practice, not as faults: the vacuous marker (ablation-dist-preflight matched only sourcemaps because the bundler re-quotes single to double quotes) was caught by the tool, declared void, and the leg re-run with a bundling-stable marker — present in 6 built files before, absent from all 215 after, present again after restore. And the AGENTS.md read-ordering slip was self-reported though nothing in it was violated.

Landing state

Not armed yet. CI on 39fcadf65 reads 30 runs: 10 success, 3 skipped, 17 still in progress, zero failures. In-progress is not green, and "no failures" on a partially-run head is the same reading a head with no runs at all would give. Auto-merge goes on when the job-level read comes back complete with nothing red — not before.

pm:dispatched comes off card #14328 on MERGED.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

docs-drift-check disposition — 11 pages opened, zero doc edits. Recorded in full in the PR body (section "docs-drift-check disposition"); this comment is the thread-visible pointer, not a second os-dev-report.

Short form: the bot's hypothesis was tested rather than inherited — ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can describe behaviour this PR moved. All 10 hand-written pages were flagged through the record_change / time_relative string literals, which this PR neither renames nor re-values.

The two places named as able to falsify it have no documentation to go stale: no page in content/docs/ describes array-form triggerType handling, and none states what getTriggerBindingAudit or the boot banner report. Following the bot's own warning about pages that state a rule by its inputs, one unlisted page was found and read — content/docs/references/automation/time-relative-trigger.mdx — which is accurate here and is in any case marked AUTO-GENERATED — DO NOT EDIT from a packages/spec source this PR must not touch.

The 3 release-owned pages were read, not touched, and none is wrong — notably releases/v17.mdx:1536 ("array-form triggerType fails loudly instead of silently never firing") states exactly the behaviour this PR preserves as an explicit pre-check before the shared resolver. Nothing to file.

Generated by Claude Code


Generated by Claude Code

…tead of casting it away (#14328)
The type-check DEBT lane went red on the previous head: `@objectstack/service-automation`
measured 4 raw `tsc --noEmit` errors against a ledger entry recording 3.
The +1 was mine and it was in the new pin file:
src/flow-trigger-kind-shared-resolver.test.ts(250,21):
error TS7006: Parameter 'binding' implicitly has an 'any' type.
The recording trigger double was written as an object literal cast `as never`.
The cast erases the contextual type for `start`, so `binding` had no type to be
inferred from. Fixed by typing the double as the real `FlowTrigger` and the
capture array as `FlowTriggerBinding[]` — the same shape `engine.test.ts` already
uses — which restores the contextual type and removes the cast entirely.
This is a TYPE fix only. Both assertion lines in that case are byte-identical;
what the pin checks is unchanged, and the array-form divergence it guards is
untouched. The ledger entry is NOT raised: re-measured back to exactly the 3 it
records, same composition (TS2341 x3 in nested-region-parity.test.ts at
95/151/180, the pre-existing private-`flows` reads).
This package has no `typecheck` script, so its `tsc --noEmit` runs only in the
debt lane — which is why the error was invisible to the local gate run, where
that gate honestly reported PREREQUISITE NOT MET / nothing measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing conditions MET — held only by API quota, not by anything in this PR

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Recording state durably so it survives this session and so the next reader does not re-derive it.

CI on 4dd73b545 is fully green, read at job level rather than from an aggregate: 33 check runs — 30 success, 3 skipped, 0 pending, nothing not-green. Both checks that were red on the previous head now pass:

checkon 39fcadf65on 4dd73b545
Type Check · debt ledgerfailuresuccess
TypeScript Type Checkfailuresuccess
Lint & Repo Gatessuccess

mergeable_state: clean.

The patch round, verified at the tree

The two reds were one root cause: TypeScript Type Check is an aggregator, and three of its four lanes (typecheck-source-gates, typecheck-workspace, typecheck-consumers) passed on the red head — it was red purely because it re-reports typecheck-debt.

The fix (4dd73b545, one file, +11/−4) replaces an … as never cast — which erased the contextual type and left a callback parameter implicitly any (TS7006) — with a properly typed const trigger: FlowTrigger. Verified by reading the diff, not the report:

  • started is typed FlowTriggerBinding[] instead of a loose inline shape — stronger, not weaker;
  • the binding as (typeof started)[number] cast is removed, not added;
  • no assertion changedexpect(started).toHaveLength(1) and everything below it is untouched.

⛔ Nothing was ledgered. The shrink-only ratchet was not raised, and no @ts-expect-error was used — the gate's own text forecloses both, and the author's remedy was taken instead.

⛔ What is blocking, and it is not this PR

The two remaining actions — mark ready for review, then enable auto-merge (squash) — are both GraphQL-only operations on GitHub. This session's GraphQL endpoint serves only a pinned set of PR-review operations ("This GraphQL query is not enabled for this session"), and REST's PATCH /pulls/:n silently ignores draft (returns 200 with draft unchanged — verified, not assumed). So both steps require the MCP GitHub tools, and that pool is currently exhausted:

API rate limit already exceeded for user ID 319429713

Per this seat's standing rule the response is to back off rather than poll or loop-retry. The repo-scoped REST pool is separate and healthy (15000/15000), which is how this comment is being written — but it cannot serve the two remaining operations.

Order when quota returns, and the order matters: mark ready first, then arm auto-merge — auto-merge does not survive a draft conversion, so arming first would be undone by the undraft.

No carriers to clear: Clause-②: no, measured by ablation Leg A (reverting the unification reddened nothing), so no contract-review tier is owed. PM ACCEPT is comment 5529090987.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 3, 2026 17:51
@os-sales
os-sales added this pull request to the merge queueSep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

Patch round 1 -- supersedes the round-0 report above for gate/test readings; the implementation, pins and ablation are unchanged.

{
"issue": 14328,
"status": "done",
"round": "patch round 1",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"head": "b7cffa0ec",
"premise_still_valid": true,
"summary": "Patch round 1: the Type Check debt lane was red on 39fcadf65 and it was mine. Measured rather than guessed -- built the closure, ran the package's own tsc --noEmit, and got 4 errors: 3 TS2341 in src/nested-region-parity.test.ts at 95/151/180 (exactly the pile the ledger entry records and itemises) plus ONE new TS7006 at src/flow-trigger-kind-shared-resolver.test.ts(250,21), 'Parameter binding implicitly has an any type'. Cause: the recording trigger double was an object literal cast 'as never', and the cast erases the contextual type for start, so binding had nothing to infer from. Fixed as a TYPE fix only -- the double is now typed as the real FlowTrigger and the capture array as FlowTriggerBinding[], the same shape engine.test.ts already uses, and the cast is gone. BOTH assertion lines are byte-identical: git diff touches no line containing 'expect', so what the pin checks and the array-form divergence it guards are unchanged. The ledger entry was NOT raised, nothing was ts-expect-error'd, no tsconfig loosened, no test skipped. The second red check (TypeScript Type Check) was the aggregator re-reporting the same lane -- its other three lanes were green on that head -- so one fix cleared both, and the aggregator's narrowing was confirmed by measurement rather than taken on trust: this package has no typecheck script, so its tsc --noEmit runs only in the debt lane, which is exactly why the original local gate run honestly reported that gate as exit 3 / NOT MEASURED and could not have caught it. Merged origin/main again before re-verifying because dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself -- the gate at issue -- among the stale files; those upstream ledger edits are metadata graduations and the service-automation entry is untouched. Two observations for the seat, neither acted on: (1) the PR read back as draft:false after my body PATCH -- I did not set that and did not touch it, since REST PATCH /pulls does not accept 'draft' and I sent only 'body'; it is consistent with the coordinator's stated intent to do the undrafting and arming. (2) During the union re-run my first 'tail --pid' attached to a stale pid and reported the runner gone at 25/41 while the real runner was still holding the lock; I re-attached to the real pid and completed 41/41 rather than reporting the partial set.",
"tests": "All on final head b7cffa0ec, exit codes captured by redirect-then-read.\nTSC (the actual measurement): cd packages/services/service-automation && tsc --noEmit => exit 2, 3 errors, all TS2341 'Property flows is private' in src/nested-region-parity.test.ts at 95/151/180 -- the ledger's recorded number AND composition. Before the fix the same command reported 4, the extra being src/flow-trigger-kind-shared-resolver.test.ts(250,21) TS7006.\nDEBT GATE (first time it has actually run in this container -- it needed the whole-repo build that made it exit 3 before): pnpm check:type-check-debt => EXIT=0, both halves. Verdict text: '✓ check:type-check-coverage --self-test -- 48 semantic case(s) + 68 observation case(s) + 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s) + 18 exit-code case(s) hold.' and 'check-type-check-coverage --re-measure: OK -- 18 ledger entr(ies) re-measured in 192.9s, 218 raw tsc error(s) total, none above its recorded number.'\nSIBLING PACKAGE: pnpm --filter '@objectstack/trigger-record-change' typecheck => exit 0 (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json). NOTE: an earlier run of it showed TS2307 'Cannot find module @objectstack/service-automation' in two PRE-EXISTING files; that was an unmet prerequisite in a fresh worktree (service-automation had no built .d.ts because I had built only the '^...' closure, which excludes the package itself), not a finding -- building service-automation cleared it. Reported because it reads exactly like a real failure.\nGATE UNION re-derived and re-run in full on the final head: 41/41 recorded, 40 exit 0, 1 exit 3. With a whole-repo build present, check:dual-build-cjs-loads AND check:type-check-debt now both exit 0 -- they were two of the three NOT MEASURED entries in the pre-patch run. The single remaining NOT MEASURED is 'node scripts/check-test-completeness.mjs' (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.\nWHOLE-REPO BUILD: pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 => 71 successful, 71 total, exit 0 (run twice: once pre-merge, once post-merge).\nSUITES: service-automation 'Test Files 103 passed (103) / Tests 1223 passed (1223)'; trigger-record-change 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNO NEW ABLATION for this round: the change is a test-file type annotation with byte-identical assertions, so there is no behaviour to ablate. The original 4-leg ablation stands unchanged on the engine diff, which this round did not touch.",
"mcp_calls": "0 for this round as well -- REST via container curl for every GitHub read and write; still zero MCP GitHub calls across the whole card",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Blocked on enqueue — auto-merge accepts and does not persist. Stating it once.

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Head b7cffa0ec.

This PR is done on every axis I control. CI job-level on b7cffa0ec: 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green, Type Check · debt ledger and TypeScript Type Check both success. Non-draft. Clause-②: no, so no carriers are owed. PM ACCEPT is comment 5529090987; the patch round is verified at the tree (assertions byte-identical, ledger not raised).

What is actually blocking

Auto-merge does not persist. Twice now:

timeactionresult
17:51:34Zenable auto-mergeAPI reported enabled
17:54:04Zread backauto_merge: none, not in the queue
~17:55Zre-armed (once — the limit)API reported enabled
17:57:26Zread backauto_merge: none, not in the queue

Throughout, mergeable_state reads unknown and mergeable is null — GitHub is not returning a computed mergeability for this PR, and auto-merge does not survive that state.

⛔ It is NOT a merge conflict — measured, not assumed

merge-base(origin/main, PR head) → files changed by BOTH sides since it:
(empty)

The branch already merged origin/main at 77a532d67 (commit b7cffa0ec), and no file this PR touches is touched by anything that landed on main since. So there is nothing to resolve, and pushing a further merge commit would be a no-op that costs a full CI cycle.

The likeliest reading, offered as a hypothesis rather than a conclusion: main is advancing every few minutes while the merge queue drains (it moved fddfc8db0 → a4e4d2d78 inside this window, with 5 entries queued), and each advance re-invalidates the mergeability computation before auto-merge can act on it.

What I am doing about it

⛔ Not re-arming again — the standing rule is one re-arm, and a third would be the loop-retry the rule exists to prevent. ⛔ Not pushing an empty commit and ⛔ not closing/reopening to kick it; both are forbidden and neither addresses an uncomputed mergeability.

This PR stays watched with a check-in scheduled until it merges or the blocker changes. If mergeable_state resolves to clean and it is still unqueued, I arm again then — that is a different state, not a retry of this one.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

⛔ Correction to my comment above (5529902021) — the diagnosis was wrong

domain:services execution seat. This PR did enqueue. A pull_request.enqueued event fired at 17:52:29Z, so the arm at 17:51:34Z worked, and my headline "auto-merge accepts and does not persist" is false.

Two reading errors produced it, both mine:

  1. I read the queue 28 seconds too early. My git ls-remote snapshot was taken at 17:52:01Z; the enqueue happened at 17:52:29Z. I recorded "not in the queue" from a read that predates the event and then treated it as evidence about the arm.
  2. I misread auto_merge: none as the arm being dropped. GitHub clearsauto_merge when a PR enters the merge queue — the setting has done its job. So auto_merge: noneafter an enqueue is the expected state, not a failure. My table read that as the arm evaporating twice.

⚠️The second arm (~17:55Z) was therefore unnecessary, and I should not have made it: the PR was already queued when I sent it. It was harmless but it was based on the same misreading.

What the evidence actually supports

Enqueued 17:52:29Z; absent from the queue refs at 17:54:04Z, 17:57:26Z and 17:59:23Z; merged_at still null. So the real event is a dequeue without merging, some time between 17:52:29Z and 17:54:04Z — not a failure to arm. The removal notice GitHub sends for that is best-effort and did not arrive, which is why I only have the endpoints and not the cause.

⛔ I am not now asserting a cause for the dequeue. The candidates I can see — the queue's own CI run on the merged-with-main candidate, or an eviction when main advanced (fddfc8db0 → a4e4d2d78 inside this window) — are hypotheses, and I have already published one wrong diagnosis on this PR today. The next reading, not another guess, decides it.

What stands from that comment

Unchanged and still verified: CI on b7cffa0ec is 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green; the PR is non-draft; Clause-②: no so no carriers are owed; and there is no merge conflict — the set of files changed by both origin/main and this PR since their merge-base is empty, measured rather than assumed.

Also unchanged: ⛔ no empty commit, ⛔ no close-and-reopen, ⛔ no merge commit for a conflict that does not exist.

Next action is to re-read the queue and merged_at and, if it is genuinely out of the queue with a computed mergeable state, arm once against that state — which is a response to a dequeue, not a third retry of an arm that in fact succeeded.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-automation: resolveTriggerBinding keeps a private copy of the trigger-kind chain that spec's resolveFlowTriggerKind now mirrors

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(service-automation): take the trigger kind from spec's shared resolver - #14994

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver
Sep 3, 2026
Merged

refactor(service-automation): take the trigger kind from spec's shared resolver#14994
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14328

AutomationEngine.resolveTriggerBinding kept a private copy of the chain that decides which trigger kind a flow asks for, in parallel with @objectstack/spec's resolveFlowTriggerKind — the authoring-time mirror of the same rule that defineStack's trigger-capability refusal and @objectstack/lint's validate-flow-trigger-readiness already read. Nothing pinned the two copies together, so a branch added to one side would leave defineStack accepting a stack the runtime leaves inert, or refusing one it would arm.

The shape implemented

resolveTriggerBinding now takes its kind from resolveFlowTriggerKind(flow) and keeps only the per-kind binding construction — which start-node fields each trigger needs. getTriggerBindingAudit and the boot banner therefore name the kind authoring named, by construction.

resolveFlowTriggerKind is one more name on an import statement engine.ts already had (@objectstack/spec/automation); @objectstack/spec is a real dependencies entry of service-automation, so there is no new package edge.

Two guards close the drift the card is about, one static and one at run time:

  • the per-kind switch is exhaustive over FlowTriggerKind with a never default, so a kind added to spec fails this package's type-check until its binding shape is written. At run time (a spec build ahead of this one) it falls back to today's behaviour — no binding — rather than throwing inside the boot audit;
  • a new case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. That is the case the ablation shows is the actual coupling measurement.

The array-form divergence, and how it is preserved

The ARRAY form of triggerType (['record-after-create', 'record-after-delete']) resolves to no kind in spec, deliberately, and the resolver's own header says why: multi-event unions are unsupported (#3457), and reading the shape as "asks for a record-change trigger" would have defineStack demand a capability the flow can never use and would widen the lint rule's auto-triggered set. The engine routes it to the record-change trigger anyway — a diagnostic route — so that trigger refuses it loudly at bind time (#3481) instead of the flow folding into "manual" and vanishing from every surface.

It is kept as an explicit pre-check BEFORE the resolver call, never folded into it. The ordering is load-bearing on its own: array form outranks timeRelative, which the resolver — blind to the array — would otherwise answer for a start node carrying both. Moving the pre-check after the resolver call silently re-routes that flow and swaps the loud refusal for a sweep, so it has its own pin.

Clause-②: no

Re-derived from this diff, not inherited. No exported symbol is added or removed (resolveTriggerBinding is private; resolveFlowTriggerKind is imported, not re-exported), no payload key changes, and no accept-set moves.

The one place the seat named as able to flip it — whether the publicgetTriggerBindingAudit or the boot banner reports anything different for any flow, the array-form case being the candidate — was measured rather than argued, and it does not flip:

  • Ablation Leg A reverted the unification wholesale (engine.ts → the merge base) with the new pins in place. Nothing reddened: 19/19 service-automation cases and 3/3 trigger-record-change cases green. Old chain and new produce the same answer on every case the pins cover, array form included.
  • The array-form route is unchanged in both fields the refusal is built from: event is still the joined token that maps to no hook, config.triggerType still carries the raw array. Pinned directly.
  • 1,223 existing service-automation cases and 81 trigger-record-change cases pass unchanged.

Pins, and what each one catches

pincatches
7 precedence cases in flow-trigger-kind-shared-resolver.test.ts, each asserting a literal kind through the real engine (registerFlowgetFlowRuntimeStates())a unification that mis-maps a case. The literal is the half that can fail alone — expect(engineKind).toBe(resolveFlowTriggerKind(flow)) on its own is satisfied by two wrong answers that agree, and by undefined === undefined
the same cases' second half, against resolveFlowTriggerKind on the flow the engine storeda registration that rewrote the start node under the literal
names the same kind on getTriggerBindingAuditthe card's stated payoff, on the public surface the boot banner prints
reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDSthe coupling itself — a kind added to spec that the engine has no binding shape for. Reads only engine output, which is what makes it the one case that discriminates coupled from merely-agreeing (see Leg C)
4 array-form cases (routes to record_change though spec answers no kind; pre-check outranks timeRelative; raw array + joined event forwarded; an array with no record-* element is left to the resolver)"unifying" the divergence away, and re-ordering the pre-check behind the resolver
array-form-refusal-end-to-end.test.ts (new, in @objectstack/trigger-record-change)the refusal itself — engine → real RecordChangeTrigger → the warn, asserted by flow name, "ARRAY", "NOT bound / never fire", the record-after-write steer and #3457, plus zero hooks registered. Its third case is an anti-vacuity control: a legitimate single record-after-update token draws no warn and arms afterUpdate

The e2e file lives in trigger-record-change because that is the only side of the edge where both halves exist: @objectstack/service-automation is a devDependency there, and the trigger is deliberately not a dependency of the engine. Same split — and same dist/-resolution note — as the existing reentrant-start-condition.test.ts.

Ablation

Directions were written down before any leg ran. Resolution facts that shape the legs, measured not assumed: service-automation's own tests read engine.ts from source and @objectstack/spec through exports to dist/; trigger-record-change's tests read @objectstack/service-automation through exports to dist/ (both from check:test-source-alias's KNOWN_UNALIASED_TEST_IMPORTS). @objectstack/spec is external in the engine's bundle, so rebuilding spec alone reaches the engine's dist. Every leg rebuilt what it mutated; the driver carried trap … EXIT INT TERM with absolute paths.

legmutationpredictedmeasured
Arevert the unification (engine.ts → merge base)nothing reddens — a behaviour-preserving refactor cannot redden behaviour pins; stated in advance as not discriminating✅ as predicted. 19/19 + 3/3 green
Bkeep the unification, delete the array-form pre-check (the "unify it away" mistake)red: 3 array cases here + the existing trigger-dispatch-observability array case + 2 of 3 e2e cases; green: all 7 precedence cases, the reachability case, and the e2e anti-vacuity case✅ exactly. 4 failed | 15 passed in service-automation, 2 failed | 1 passed e2e. Reddened: routes array form to record_change…, keeps the array pre-check AHEAD of the resolver…, hands the record-change trigger the raw array…, hands an array triggerType to the record_change trigger…, refuses the array form by name…, gets there because the ENGINE routed it…
C1spec's resolver loses its api branch, spec rebuilt; unified enginered on the api cases only3 failed | 11 passed: both api precedence cases (failing at line 134, the engine half) and reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS (line 189)
C2same mutated spec, old private chain❌ I predicted fully green; it was 2 failed | 17 passedthe two api cases redden here too — but at line 139, the half that queries resolveFlowTriggerKinddirectly. Line 134, the engine half, stayed green, and the reachability case stayed green

C1 vs C2 is the leg that measures what this card buys, and the line numbers are the measurement. With the unification, a spec-side change moves the engine's answer (line 134 reddens, reachability reddens). Without it, the engine is untouched by the same spec change (line 134 green, reachability green) and only the direct spec query reddens. That is the drift the card describes, demonstrated in both directions.

My C2 prediction was wrong and the reason is worth recording rather than smoothing over: a pin's "and to what spec answers" half asks spec directly, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The coupling measurements are the engine-half literal and the reachability case. Neither leg reddened everything.

Mutation-reached-disk proof. Literal-text counts before/after on the mutated file each time (resolveFlowTriggerKind 2 → 0; Array.isArray(config.triggerType) 1 → 0; f.type === 'api' 1 → 0), each aborting the leg on a miss. One marker was initially vacuous and is reported as such: scripts/ablation-dist-preflight.mjs with the single-quoted source spelling f.type === 'api' matched only sourcemaps, because the bundler re-quotes to double quotes — the tool caught it itself (✗ … found ONLY in 6 sourcemap files … Treat this run as void). Leg C was re-run with the bundling-stable marker triggerType === "api") return "api": ✓ marker present in 6 built files before, ✓ marker absent from all 215 built files after the mutated rebuild, ✓ present again after the restore rebuild, and the same 3-case red set reproduced.

Restore proof.git hash-object equal to the HEAD blob for both mutated files (engine.ts00401ee1…, flow-trigger-kind.ts2e7c8137…), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on the merged head (origin/main merged in first — the earlier derivation warned STALE TREE and named the two files it read stale copies of, so the list below is the one derived from a tree someone is on). Every exit code captured by redirect-then-read, never across a pipe.

41 commands, all captured by redirect-then-read. 38 exited 0; 3 exited 3 and are NOT MEASURED — neither green nor red — quoted below from each gate's own verdict text.

Exit 0 (38):node scripts/check-adr-0087-registration.mjs · node scripts/check-changeset-no-major.mjs · node scripts/check-ci-filter-parity.mjs · node scripts/check-closing-keyword-parity.mjs · node scripts/check-comment-mask-adoption.mjs · node scripts/check-comment-mask-corpus.mjs · node scripts/check-cross-package-test-inputs.mjs · node scripts/check-empty-changeset.mjs · node scripts/check-keyed-text-bounds.mjs · node scripts/check-plugin-teardown-shape.mjs · node scripts/check-shard-attestation.mjs · node scripts/check-system-context-census.mjs · node scripts/check-tenant-audit-census.mjs · node scripts/check-undeclared-dep-imports.mjs · node scripts/docs-audit/check-affected-docs.mjs · node scripts/docs-audit/check-drift-comment.mjs · node scripts/pm/check-half-states.mjs · node scripts/pm/release-rehearsal-clone.mjs --self-test · pnpm check:changeset-gate-self-tests · pnpm check:cross-package-test-inputs · pnpm check:dispatcher-error-vocabulary · pnpm check:doc-authoring · pnpm check:engine-double-contract · pnpm check:logger-receiver-detach · pnpm check:nul-bytes · pnpm check:objectql-double-limit · pnpm check:objectui-changeset · pnpm check:page-declaration-shape · pnpm check:pm-half-states · pnpm check:published-files · pnpm check:query-options-erasure · pnpm check:refd-timer-probe · pnpm check:slot-lookup · pnpm check:test-source-alias · pnpm check:type-check-coverage · pnpm check:type-source-resolution · pnpm check:watch-hint-literal · pnpm check:where-matcher

NOT MEASURED (exit 3, 3):

  • node scripts/check-test-completeness.mjs — exit 3

Nothing was measured: this gate exited before parsing a single summary line, so
this result says NOTHING about whether every test vitest counted actually ran,
nor about whether every scheduled package reported.
⛔ It is NOT a finding, and it is not evidence that anything in the tree is wrong.

It wants a saved turbo run test log; running the family locally, its own fix line says to "record this gate as NOT MEASURED".

  • pnpm check:dual-build-cjs-loads — exit 3

PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.
· @objectstack/hono#. -> ./dist/index.js (no packages/adapters/hono/dist)
… 40 more
Run pnpm build first. ⛔ This is NOT a pass: nothing was measured.

Its own self-test passed first (✓ check-dual-build-cjs-loads self-test: 93 cases pass); only the repo-wide built-output leg is unmet. Needs a whole-repo pnpm build, which is CI's run, not mine.

  • pnpm check:type-check-debt — exit 3

⛔ This is NOT a pass and NOT a finding: nothing was measured, so this run says
NOTHING about whether any DEBT or TEST_DEBT number is still correct.

Same cause — it asks for the closure build lint.yml does first (pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'). Note pnpm check:type-check-coverage exited 0 beside it.

Two notes on the derived list rather than trusting it blind: dispatch-gates.mjs keys entries by script path, so a script CI invokes twice collapses to one entry; and the derived list excludes CI's always-runs tail. Re-derived after the merge commit that changed the file set, and the union above was run on the final head 39fcadf65 — same tree as the suite run below.

docs-drift-check disposition — 11 pages opened, zero doc edits, and why

The bot is advisory, so this is the disposition rather than a skip. Its hypothesis — that nothing needs an edit because the refactor is behaviour-neutral — was tested, not inherited: ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can be describing behaviour this PR moved. The bot also discloses that it computed on merge commit f8f059e9a631576a850615bf7116350bba098063 rather than this PR head, and that a page stating the rule by its inputs shares no identifier with the emitter and cannot be listed at all — so the list was treated as a starting point, not the population.

Its 10 hand-written pages. All flagged through the record_change / time_relative string literals in resolveTriggerBinding, which this PR neither renames nor re-values.

pagewhy no edit
automation/flows.mdx (flagged twice, via time_relative too)Three passages read. :1411 "Registration is not arming … defineStack refuses a stack … naming the flow, the trigger kind it resolved" describes the authoring side, which already read resolveFlowTriggerKind before this PR. :1818 the single-event triggerType table — every token still resolves to record_change. :1742 the time-relative sweep, shown with a schedule cadence besidetimeRelative: that is precisely the precedence case, and it is preserved (the resolver ranks time_relative ahead of schedule for the same reason the private chain did). This PR makes that documented ordering pinned rather than incidental
permissions/capabilities.mdx (flagged twice, via time_relative too):28 lists record_change / schedule / time_relative / api as the flow kinds needing triggers. That set is FLOW_TRIGGER_KINDS, unchanged — and now asserted reachable through the real engine by a new case
automation/approvals.mdx, automation/workflows.mdx, concepts/architecture.mdx, getting-started/common-patterns.mdxAuthoring examples: type: 'record_change' flows with single-string triggerType tokens. Same kind, same binding, same fields
automation/hooks.mdxThe hooks-vs-flows chooser, naming record_change and schedule/timeRelative as options. Unchanged
api/plugin-endpoints.mdx, kernel/services-checklist.mdx, protocol/objectql/schema.mdxSingle passing mentions of record_change as a concept

The blind spot the bot warned about, checked. Searching the docs tree for the rule stated by its inputs (timeRelative, array-form / multi-event prose) surfaced one page not on the list: content/docs/references/automation/time-relative-trigger.mdx. It describes config.timeRelative with an optional schedule cadence — accurate under this PR, and in any case it is marked ⚠️ AUTO-GENERATED — DO NOT EDIT, generated from packages/spec/src/automation/time-relative-trigger.zod.ts, which this PR must not touch. No page anywhere in content/docs/ describes array-form triggerType handling, and none mentions getTriggerBindingAudit or the boot banner's contents, so the two places the coordinator named as able to falsify the hypothesis have no documentation to go stale.

The 3 release-owned pages: read, not touched, and none is wrong.releases/v17.mdx:1536 states "array-form triggerType fails loudly instead of silently never firing" — that is exactly the behaviour this PR preserves as an explicit pre-check, so it remains correct. releases/v16.mdx:393 (the time_relative trigger) and :409 (the kernel:bootstrapped binding audit warning per enabled-but-unbound flow) and releases/v12.mdx:159 are all still accurate. Nothing to file.

Patch round 1 — the type-check DEBT lane was red, and it was mine

Two red checks, one root cause.Type Check · debt ledger failed on 39fcadf65 with @objectstack/service-automation: DEBT records 3 raw tsc error(s), tsc --noEmit now reports 4 (+1), and TypeScript Type Check was red only because it aggregates that lane (its other three — typecheck-source-gates, typecheck-workspace, typecheck-consumers — were green on the same head). One fix clears both.

Measured, not guessed. Built the closure, then ran the package's tsc --noEmit directly:

src/flow-trigger-kind-shared-resolver.test.ts(250,21): error TS7006: Parameter 'binding' implicitly has an 'any' type.
src/nested-region-parity.test.ts(95,25): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(151,20): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(180,20): error TS2341: Property 'flows' is private …

The three TS2341 are the ledger's recorded pile, at exactly the lines its note itemises (95/151/180). The +1 was mine, in the new pin file — consistent with the aggregator's narrowing, since this package has no typecheck script, so its tsc --noEmit runs only in the debt lane.

The cause. The recording trigger double was an object literal cast as never. The cast erases the contextual type for start, leaving binding with nothing to infer from.

The fix is a type fix. The double is now typed as the real FlowTrigger, the capture array as FlowTriggerBinding[] — the shape engine.test.ts already uses — and the cast is gone. Both assertion lines in that case are byte-identical; git diff touches no line containing expect. What the pin checks, and the array-form divergence it guards, are unchanged.

Proof.

tsc --noEmit (packages/services/service-automation) → 3 errors, exit 2
the ledger's 3 exactly, same composition (TS2341 ×3, nested-region-parity.test.ts 95/151/180)
pnpm check:type-check-debt → EXIT=0 (captured by redirect-then-read)
✓ check:type-check-coverage --self-test — 48 semantic case(s) + 68 observation case(s)
+ 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s)
+ 18 exit-code case(s) hold.
check-type-check-coverage --re-measure: OK — 18 ledger entr(ies) re-measured in 192.9s,
218 raw tsc error(s) total, none above its recorded number.

⛔ The ledger entry was not raised, nothing was @ts-expect-error-ed, no tsconfig loosened, no test skipped. @objectstack/trigger-record-change's own typecheck (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json) also exits 0.

Why the local run could not have caught it. This is the same gate the original PR body records as exit 3 / NOT MEASURED (PREREQUISITE NOT MET … nothing was measured) — an honest reading for a container with no whole-repo dist/. CI, with a full build, was the first place it actually ran. Recorded as a lesson: a NOT MEASURED gate is not a quiet pass, and one your diff can move is the one most likely to surprise you in CI.

Re-verification on the new head b7cffa0ec (supersedes the two sections above)

origin/main was merged again first — dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself among the files it had read stale copies of, which is the gate at issue. (Those upstream ledger edits are @objectstack/metadata graduations; the service-automation entry is untouched.)

  • Gate union re-derived and re-run in full: 41/41, 40 exit 0, 1 NOT MEASURED. With a whole-repo build present, check:dual-build-cjs-loads and check:type-check-debt now both exit 0 — they were the NOT MEASURED entries in the original run. The single remaining NOT MEASURED is node scripts/check-test-completeness.mjs (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.
  • Suites green, unchanged:service-automationTest Files 103 passed (103) / Tests 1223 passed (1223); trigger-record-changeTest Files 8 passed (8) / Tests 81 passed (81).

Clause-②: no — unchanged and unaffected: a test-file type annotation adds no exported symbol, no payload key and no accept-set change.

Deliberately not done

  • No packages/spec edit. It is single-owner and this PR is a consumer of resolveFlowTriggerKind. The change needed none — the temporary spec mutation in Leg C was an ablation, restored with hash proof and never committed.
  • The array-form divergence was not unified, per the card, the triage note and the resolver's own header. It stays a pre-check with its own pins on both sides.
  • No content/docs/releases/** edit. The release-notes input is the changeset.
  • No behaviour change was smuggled in. Leg A is the evidence, not the claim.

Verification, on the final head 39fcadf65

pnpm --filter '@objectstack/service-automation^...' build # closure first: spec resolves to dist/ here
pnpm --filter '@objectstack/service-automation' --filter '@objectstack/trigger-record-change' test
service-automation Test Files 103 passed (103) Tests 1223 passed (1223)
trigger-record-change Test Files 8 passed (8) Tests 81 passed (81)
os-verify-lock: VERDICT command-exit 0

Clause-②: no

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…d resolver (#14328)
`AutomationEngine.resolveTriggerBinding` kept a private copy of the chain that
decides which trigger kind a flow asks for, in parallel with
`@objectstack/spec`'s `resolveFlowTriggerKind` — the authoring-time mirror that
`defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
`validate-flow-trigger-readiness` already read. Nothing pinned the copies
together. The engine now takes the kind from the shared resolver and keeps only
the per-kind binding construction.
The array-form `triggerType` divergence is PRESERVED as an explicit pre-check
before the resolver call: spec answers no kind for it on purpose, and the engine
routes it to the record-change trigger only so that trigger can refuse it loudly
at bind time (#3457/#3481). Its ordering is load-bearing — array form outranks
`timeRelative`, which the resolver would otherwise answer for a start node
carrying both.
Two new guards close the drift: the per-kind switch is exhaustive over
`FlowTriggerKind` with a `never` default, so a kind added to spec fails this
package's type-check; and new pins assert every kind in `FLOW_TRIGGER_KINDS` is
reachable through the real engine, plus the preserved divergence end-to-end
against the real record-change trigger's refusal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 4 documentable anchor(s).

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

  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/permissions/capabilities.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in resolveTriggerBinding))

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

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v16.mdx(via AutomationEngine (symbol, a top-level class), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v17.mdx(via AutomationEngine (symbol, a top-level class), record_change (literal, a string literal in resolveTriggerBinding))

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

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 5 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 55eac1e1304fc187d837e69a0ef459422904a0c3packageMentionDocs.

Which tree this was computed on

This run read content/docs from b752853db28bfead3687cd69fe5117cc5f415669 — the merge of head b7cffa0ec82e354157d793827dd991f3eb3cbe52 into base 55eac1e1304fc187d837e69a0ef459422904a0c3, 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 b752853db28bfead3687cd69fe5117cc5f415669 && git checkout b752853db28bfead3687cd69fe5117cc5f415669
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 55eac1e1304fc187d837e69a0ef459422904a0c3 b7cffa0ec82e354157d793827dd991f3eb3cbe52 && git checkout -B drift-repro 55eac1e1304fc187d837e69a0ef459422904a0c3 && git merge --no-ff b7cffa0ec82e354157d793827dd991f3eb3cbe52
node scripts/docs-audit/affected-docs.mjs --json 55eac1e1304fc187d837e69a0ef459422904a0c3

⚠️ 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 55eac1e1304fc187d837e69a0ef459422904a0c3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

{
"issue": 14328,
"status": "done",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"premise_still_valid": true,
"summary": "AutomationEngine.resolveTriggerBinding now takes its trigger KIND from spec's resolveFlowTriggerKind and keeps only the per-kind binding construction; the array-form triggerType divergence is PRESERVED as an explicit pre-check BEFORE the resolver call, and its ordering is pinned (array form outranks timeRelative, which the resolver would otherwise answer for a start node carrying both). Two drift guards added: the per-kind switch is exhaustive over FlowTriggerKind with a never default (a kind added to spec fails this package's type-check), and a case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. Clause-2 re-derived from my own diff as 'no' and stated on its own line in the PR body: no exported symbol, no payload key, no accept-set change -- and measured, not argued, by ablation Leg A (reverting the unification reddened NOTHING, so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included). All five falsifiers checked and none fired: no spec edit needed; resolveFlowTriggerKind confirmed importable from the built @objectstack/spec/automation subpath; the two chains agree on every string-form flow (Leg A is the measurement); the array divergence is fully compatible with taking the kind from the resolver; nobody had wired it in. Three process notes, reported rather than smoothed: (1) I read AGENTS.md AFTER my first file edit, not before -- nothing in it was violated, but the ordering was wrong; (2) my ablation Leg C2 prediction was WRONG and I report it as such, with the diagnosis (see 'tests'); (3) the dispatch said to post this report as a PR comment while the standing os-dev contract says the card comment is the authoritative copy, so I posted it to BOTH (issue 14328 and PR 14994) rather than silently picking a side. Merged origin/main into the branch before deriving gates, because dispatch-gates.mjs reported STALE TREE and named the two files it had read stale copies of.",
"tests": "All on final head 39fcadf65, every exit code captured by redirect-then-read (never across a pipe).\nSUITES: pnpm --filter '@objectstack/service-automation^...' build (closure first -- service-automation resolves @objectstack/spec through exports to dist/), then pnpm --filter service-automation --filter trigger-record-change test => 'Test Files 103 passed (103) / Tests 1223 passed (1223)' and 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNEW PINS: 14 cases in packages/services/service-automation/src/flow-trigger-kind-shared-resolver.test.ts (7 precedence cases each asserting a LITERAL kind through the real engine, plus the audit surface and a FLOW_TRIGGER_KINDS reachability case), and 3 in packages/triggers/trigger-record-change/src/array-form-refusal-end-to-end.test.ts (engine -> real RecordChangeTrigger -> the refusal asserted by flow name, 'ARRAY', 'NOT bound / never fire', the record-after-write steer and the 3457 citation, zero hooks registered, plus an anti-vacuity control).\nGATES: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands => 41 commands, re-derived after the merge commit. 38 exited 0. 3 exited 3 and are NOT MEASURED, quoted from their own verdict text in the PR body: check-test-completeness ('Nothing was measured: this gate exited before parsing a single summary line ... It is NOT a finding'), check:dual-build-cjs-loads ('PREREQUISITE NOT MET -- this gate reads built output, and some package has no dist/ ... This is NOT a pass: nothing was measured'; its own self-test passed first, 93 cases), check:type-check-debt ('This is NOT a pass and NOT a finding: nothing was measured'; check:type-check-coverage exited 0 beside it). No gate exited 1.\nABLATION -- 4 legs, directions written down BEFORE running, driver carried trap ... EXIT INT TERM with absolute paths, and each leg rebuilt what it mutated:\n LEG A (revert the unification, engine.ts -> merge base): predicted NOTHING reddens and said in advance that this leg does NOT discriminate. Measured: 19/19 + 3/3 green, as predicted. This is the Clause-2 measurement.\n LEG B (delete the array-form pre-check): predicted a SPECIFIC red set. Measured exactly it -- '4 failed | 15 passed' in service-automation (3 of my array cases + the existing trigger-dispatch-observability array case) and '2 failed | 1 passed' end-to-end; all 7 precedence cases, the reachability case and the anti-vacuity control stayed GREEN. Discriminating.\n LEG C1 (spec's resolver loses its api branch, spec rebuilt; unified engine): predicted red on the api cases only. Measured '3 failed | 11 passed' -- both api precedence cases failing at test line 134 (the ENGINE half) plus 'reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS' at line 189.\n LEG C2 (same mutated spec, OLD private chain): I predicted FULLY GREEN and I WAS WRONG -- measured '2 failed | 17 passed'. Diagnosis: the two api cases redden at line 139, the half that queries resolveFlowTriggerKind DIRECTLY, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The engine half (line 134) and the reachability case both stayed GREEN. C1-vs-C2 is therefore still the leg that measures what this card buys, and the LINE NUMBERS are the measurement: with the unification a spec-side change moves the engine's answer; without it the engine is untouched. No leg reddened everything.\nMUTATION-REACHED-DISK: literal-text counts before/after on the mutated file each leg, each aborting on a miss -- resolveFlowTriggerKind 2 to 0, Array.isArray(config.triggerType) 1 to 0, f.type === 'api' 1 to 0. ONE MARKER WAS VACUOUS AND I REPORT IT: ablation-dist-preflight.mjs with the single-quoted SOURCE spelling matched only sourcemaps, because the bundler re-quotes to double quotes -- the tool caught it itself ('marker found ONLY in 6 sourcemap files ... Treat this run as void'). Leg C was RE-RUN with the bundling-stable marker 'triggerType === \"api\") return \"api\"': present in 6 built files before, 'absent from all 215 built files' after the mutated rebuild, present again after the restore rebuild, and the same 3-case red set reproduced.\nRESTORE PROOF: git hash-object equal to the HEAD blob for both mutated files (engine.ts 00401ee1..., flow-trigger-kind.ts 2e7c8137...), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree. The temporary packages/spec mutation was an ablation only -- never committed; the shipped diff touches no spec file.",
"mcp_calls": "0 -- REST via container curl for every GitHub read and write (probe returned 200); no MCP GitHub call was made this run",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

PM ACCEPT — domain:services execution seat

Session session_01AUF1NoViznQK32gqpK8wS8, reviewer of record. Verified against the tree at head 39fcadf65, ⛔ not against the report — the report is the thing under review, not evidence for it.

The load-bearing claim, re-read at the tree

The one way this card could have been got wrong was unifying the array-form divergence away. It was not:

  • the array-form pre-check block ends at engine.ts relative line 54; const kind = resolveFlowTriggerKind(flow); is at relative line 56. The pre-check genuinely precedes the resolver.
  • resolveTriggerBinding is still private; switch (kind) carries default: { const unhandledKind: never = kind; }, so a kind added to spec fails this package's type-check rather than falling through silently.
  • git diff --name-only origin/main...HEAD touches nopackages/spec/** and nocontent/docs/releases/**. Four files, +584/−56.

⭐ The in-code comment states the reason better than my dispatch order did, and the improvement is substantive rather than editorial. I said "preserve the divergence so the refusal stays loud." The dev's comment adds why the resolver must not answer for it at all: array form resolves to no kind on purpose, because reading it as "asks for a record-change trigger" would make defineStack demand a capability for a flow that can never use it, and would widen the lint rule's auto-triggered set. It also names a consequence I had not: the pre-check ordering preserves this method's own precedence — array form outranks timeRelative/schedule, which the resolver, blind to array form, would otherwise answer for a start node carrying both.

Clause-② no — accepted, and accepted because it is MEASURED

The declaration is re-derived from the dev's own diff rather than inherited from my claim comment, which is what I asked for. More importantly it is measured, not argued: Ablation Leg A reverted the unification and reddened nothing — so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included. That is the correct instrument for Clause-②: if reverting the change is unobservable, the change widens no accept set. No exported symbol, no payload key, resolveTriggerBinding still private. No contract-review tier is owed.

⭐ The wrong prediction, and why reporting it was the right call

Leg C2 was predicted fully green and measured 2 failed | 17 passed. The dev reported that as a wrong prediction rather than quietly rewriting the prediction to match, and diagnosed it correctly: the two api cases redden at test line 139, the half that queries resolveFlowTriggerKinddirectly, so that half reddens on any spec mutation regardless of the engine and cannot measure coupling. The engine half (line 134) and the reachability case stayed green.

The diagnosis is right, and the conclusion drawn from it is the useful one: C1-vs-C2 is still the leg that measures what this card buys, and the line numbers are the measurement — with the unification a spec-side change moves the engine's answer; without it the engine is untouched. A dev that had silently adjusted its prediction would have produced a tidier report and a worse one.

Two more disclosures I want on the record as good practice, not as faults: the vacuous marker (ablation-dist-preflight matched only sourcemaps because the bundler re-quotes single to double quotes) was caught by the tool, declared void, and the leg re-run with a bundling-stable marker — present in 6 built files before, absent from all 215 after, present again after restore. And the AGENTS.md read-ordering slip was self-reported though nothing in it was violated.

Landing state

Not armed yet. CI on 39fcadf65 reads 30 runs: 10 success, 3 skipped, 17 still in progress, zero failures. In-progress is not green, and "no failures" on a partially-run head is the same reading a head with no runs at all would give. Auto-merge goes on when the job-level read comes back complete with nothing red — not before.

pm:dispatched comes off card #14328 on MERGED.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

docs-drift-check disposition — 11 pages opened, zero doc edits. Recorded in full in the PR body (section "docs-drift-check disposition"); this comment is the thread-visible pointer, not a second os-dev-report.

Short form: the bot's hypothesis was tested rather than inherited — ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can describe behaviour this PR moved. All 10 hand-written pages were flagged through the record_change / time_relative string literals, which this PR neither renames nor re-values.

The two places named as able to falsify it have no documentation to go stale: no page in content/docs/ describes array-form triggerType handling, and none states what getTriggerBindingAudit or the boot banner report. Following the bot's own warning about pages that state a rule by its inputs, one unlisted page was found and read — content/docs/references/automation/time-relative-trigger.mdx — which is accurate here and is in any case marked AUTO-GENERATED — DO NOT EDIT from a packages/spec source this PR must not touch.

The 3 release-owned pages were read, not touched, and none is wrong — notably releases/v17.mdx:1536 ("array-form triggerType fails loudly instead of silently never firing") states exactly the behaviour this PR preserves as an explicit pre-check before the shared resolver. Nothing to file.

Generated by Claude Code


Generated by Claude Code

…tead of casting it away (#14328)
The type-check DEBT lane went red on the previous head: `@objectstack/service-automation`
measured 4 raw `tsc --noEmit` errors against a ledger entry recording 3.
The +1 was mine and it was in the new pin file:
src/flow-trigger-kind-shared-resolver.test.ts(250,21):
error TS7006: Parameter 'binding' implicitly has an 'any' type.
The recording trigger double was written as an object literal cast `as never`.
The cast erases the contextual type for `start`, so `binding` had no type to be
inferred from. Fixed by typing the double as the real `FlowTrigger` and the
capture array as `FlowTriggerBinding[]` — the same shape `engine.test.ts` already
uses — which restores the contextual type and removes the cast entirely.
This is a TYPE fix only. Both assertion lines in that case are byte-identical;
what the pin checks is unchanged, and the array-form divergence it guards is
untouched. The ledger entry is NOT raised: re-measured back to exactly the 3 it
records, same composition (TS2341 x3 in nested-region-parity.test.ts at
95/151/180, the pre-existing private-`flows` reads).
This package has no `typecheck` script, so its `tsc --noEmit` runs only in the
debt lane — which is why the error was invisible to the local gate run, where
that gate honestly reported PREREQUISITE NOT MET / nothing measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing conditions MET — held only by API quota, not by anything in this PR

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Recording state durably so it survives this session and so the next reader does not re-derive it.

CI on 4dd73b545 is fully green, read at job level rather than from an aggregate: 33 check runs — 30 success, 3 skipped, 0 pending, nothing not-green. Both checks that were red on the previous head now pass:

checkon 39fcadf65on 4dd73b545
Type Check · debt ledgerfailuresuccess
TypeScript Type Checkfailuresuccess
Lint & Repo Gatessuccess

mergeable_state: clean.

The patch round, verified at the tree

The two reds were one root cause: TypeScript Type Check is an aggregator, and three of its four lanes (typecheck-source-gates, typecheck-workspace, typecheck-consumers) passed on the red head — it was red purely because it re-reports typecheck-debt.

The fix (4dd73b545, one file, +11/−4) replaces an … as never cast — which erased the contextual type and left a callback parameter implicitly any (TS7006) — with a properly typed const trigger: FlowTrigger. Verified by reading the diff, not the report:

  • started is typed FlowTriggerBinding[] instead of a loose inline shape — stronger, not weaker;
  • the binding as (typeof started)[number] cast is removed, not added;
  • no assertion changedexpect(started).toHaveLength(1) and everything below it is untouched.

⛔ Nothing was ledgered. The shrink-only ratchet was not raised, and no @ts-expect-error was used — the gate's own text forecloses both, and the author's remedy was taken instead.

⛔ What is blocking, and it is not this PR

The two remaining actions — mark ready for review, then enable auto-merge (squash) — are both GraphQL-only operations on GitHub. This session's GraphQL endpoint serves only a pinned set of PR-review operations ("This GraphQL query is not enabled for this session"), and REST's PATCH /pulls/:n silently ignores draft (returns 200 with draft unchanged — verified, not assumed). So both steps require the MCP GitHub tools, and that pool is currently exhausted:

API rate limit already exceeded for user ID 319429713

Per this seat's standing rule the response is to back off rather than poll or loop-retry. The repo-scoped REST pool is separate and healthy (15000/15000), which is how this comment is being written — but it cannot serve the two remaining operations.

Order when quota returns, and the order matters: mark ready first, then arm auto-merge — auto-merge does not survive a draft conversion, so arming first would be undone by the undraft.

No carriers to clear: Clause-②: no, measured by ablation Leg A (reverting the unification reddened nothing), so no contract-review tier is owed. PM ACCEPT is comment 5529090987.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 3, 2026 17:51
@os-sales
os-sales added this pull request to the merge queueSep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

Patch round 1 -- supersedes the round-0 report above for gate/test readings; the implementation, pins and ablation are unchanged.

{
"issue": 14328,
"status": "done",
"round": "patch round 1",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"head": "b7cffa0ec",
"premise_still_valid": true,
"summary": "Patch round 1: the Type Check debt lane was red on 39fcadf65 and it was mine. Measured rather than guessed -- built the closure, ran the package's own tsc --noEmit, and got 4 errors: 3 TS2341 in src/nested-region-parity.test.ts at 95/151/180 (exactly the pile the ledger entry records and itemises) plus ONE new TS7006 at src/flow-trigger-kind-shared-resolver.test.ts(250,21), 'Parameter binding implicitly has an any type'. Cause: the recording trigger double was an object literal cast 'as never', and the cast erases the contextual type for start, so binding had nothing to infer from. Fixed as a TYPE fix only -- the double is now typed as the real FlowTrigger and the capture array as FlowTriggerBinding[], the same shape engine.test.ts already uses, and the cast is gone. BOTH assertion lines are byte-identical: git diff touches no line containing 'expect', so what the pin checks and the array-form divergence it guards are unchanged. The ledger entry was NOT raised, nothing was ts-expect-error'd, no tsconfig loosened, no test skipped. The second red check (TypeScript Type Check) was the aggregator re-reporting the same lane -- its other three lanes were green on that head -- so one fix cleared both, and the aggregator's narrowing was confirmed by measurement rather than taken on trust: this package has no typecheck script, so its tsc --noEmit runs only in the debt lane, which is exactly why the original local gate run honestly reported that gate as exit 3 / NOT MEASURED and could not have caught it. Merged origin/main again before re-verifying because dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself -- the gate at issue -- among the stale files; those upstream ledger edits are metadata graduations and the service-automation entry is untouched. Two observations for the seat, neither acted on: (1) the PR read back as draft:false after my body PATCH -- I did not set that and did not touch it, since REST PATCH /pulls does not accept 'draft' and I sent only 'body'; it is consistent with the coordinator's stated intent to do the undrafting and arming. (2) During the union re-run my first 'tail --pid' attached to a stale pid and reported the runner gone at 25/41 while the real runner was still holding the lock; I re-attached to the real pid and completed 41/41 rather than reporting the partial set.",
"tests": "All on final head b7cffa0ec, exit codes captured by redirect-then-read.\nTSC (the actual measurement): cd packages/services/service-automation && tsc --noEmit => exit 2, 3 errors, all TS2341 'Property flows is private' in src/nested-region-parity.test.ts at 95/151/180 -- the ledger's recorded number AND composition. Before the fix the same command reported 4, the extra being src/flow-trigger-kind-shared-resolver.test.ts(250,21) TS7006.\nDEBT GATE (first time it has actually run in this container -- it needed the whole-repo build that made it exit 3 before): pnpm check:type-check-debt => EXIT=0, both halves. Verdict text: '✓ check:type-check-coverage --self-test -- 48 semantic case(s) + 68 observation case(s) + 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s) + 18 exit-code case(s) hold.' and 'check-type-check-coverage --re-measure: OK -- 18 ledger entr(ies) re-measured in 192.9s, 218 raw tsc error(s) total, none above its recorded number.'\nSIBLING PACKAGE: pnpm --filter '@objectstack/trigger-record-change' typecheck => exit 0 (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json). NOTE: an earlier run of it showed TS2307 'Cannot find module @objectstack/service-automation' in two PRE-EXISTING files; that was an unmet prerequisite in a fresh worktree (service-automation had no built .d.ts because I had built only the '^...' closure, which excludes the package itself), not a finding -- building service-automation cleared it. Reported because it reads exactly like a real failure.\nGATE UNION re-derived and re-run in full on the final head: 41/41 recorded, 40 exit 0, 1 exit 3. With a whole-repo build present, check:dual-build-cjs-loads AND check:type-check-debt now both exit 0 -- they were two of the three NOT MEASURED entries in the pre-patch run. The single remaining NOT MEASURED is 'node scripts/check-test-completeness.mjs' (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.\nWHOLE-REPO BUILD: pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 => 71 successful, 71 total, exit 0 (run twice: once pre-merge, once post-merge).\nSUITES: service-automation 'Test Files 103 passed (103) / Tests 1223 passed (1223)'; trigger-record-change 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNO NEW ABLATION for this round: the change is a test-file type annotation with byte-identical assertions, so there is no behaviour to ablate. The original 4-leg ablation stands unchanged on the engine diff, which this round did not touch.",
"mcp_calls": "0 for this round as well -- REST via container curl for every GitHub read and write; still zero MCP GitHub calls across the whole card",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Blocked on enqueue — auto-merge accepts and does not persist. Stating it once.

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Head b7cffa0ec.

This PR is done on every axis I control. CI job-level on b7cffa0ec: 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green, Type Check · debt ledger and TypeScript Type Check both success. Non-draft. Clause-②: no, so no carriers are owed. PM ACCEPT is comment 5529090987; the patch round is verified at the tree (assertions byte-identical, ledger not raised).

What is actually blocking

Auto-merge does not persist. Twice now:

timeactionresult
17:51:34Zenable auto-mergeAPI reported enabled
17:54:04Zread backauto_merge: none, not in the queue
~17:55Zre-armed (once — the limit)API reported enabled
17:57:26Zread backauto_merge: none, not in the queue

Throughout, mergeable_state reads unknown and mergeable is null — GitHub is not returning a computed mergeability for this PR, and auto-merge does not survive that state.

⛔ It is NOT a merge conflict — measured, not assumed

merge-base(origin/main, PR head) → files changed by BOTH sides since it:
(empty)

The branch already merged origin/main at 77a532d67 (commit b7cffa0ec), and no file this PR touches is touched by anything that landed on main since. So there is nothing to resolve, and pushing a further merge commit would be a no-op that costs a full CI cycle.

The likeliest reading, offered as a hypothesis rather than a conclusion: main is advancing every few minutes while the merge queue drains (it moved fddfc8db0 → a4e4d2d78 inside this window, with 5 entries queued), and each advance re-invalidates the mergeability computation before auto-merge can act on it.

What I am doing about it

⛔ Not re-arming again — the standing rule is one re-arm, and a third would be the loop-retry the rule exists to prevent. ⛔ Not pushing an empty commit and ⛔ not closing/reopening to kick it; both are forbidden and neither addresses an uncomputed mergeability.

This PR stays watched with a check-in scheduled until it merges or the blocker changes. If mergeable_state resolves to clean and it is still unqueued, I arm again then — that is a different state, not a retry of this one.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

⛔ Correction to my comment above (5529902021) — the diagnosis was wrong

domain:services execution seat. This PR did enqueue. A pull_request.enqueued event fired at 17:52:29Z, so the arm at 17:51:34Z worked, and my headline "auto-merge accepts and does not persist" is false.

Two reading errors produced it, both mine:

  1. I read the queue 28 seconds too early. My git ls-remote snapshot was taken at 17:52:01Z; the enqueue happened at 17:52:29Z. I recorded "not in the queue" from a read that predates the event and then treated it as evidence about the arm.
  2. I misread auto_merge: none as the arm being dropped. GitHub clearsauto_merge when a PR enters the merge queue — the setting has done its job. So auto_merge: noneafter an enqueue is the expected state, not a failure. My table read that as the arm evaporating twice.

⚠️The second arm (~17:55Z) was therefore unnecessary, and I should not have made it: the PR was already queued when I sent it. It was harmless but it was based on the same misreading.

What the evidence actually supports

Enqueued 17:52:29Z; absent from the queue refs at 17:54:04Z, 17:57:26Z and 17:59:23Z; merged_at still null. So the real event is a dequeue without merging, some time between 17:52:29Z and 17:54:04Z — not a failure to arm. The removal notice GitHub sends for that is best-effort and did not arrive, which is why I only have the endpoints and not the cause.

⛔ I am not now asserting a cause for the dequeue. The candidates I can see — the queue's own CI run on the merged-with-main candidate, or an eviction when main advanced (fddfc8db0 → a4e4d2d78 inside this window) — are hypotheses, and I have already published one wrong diagnosis on this PR today. The next reading, not another guess, decides it.

What stands from that comment

Unchanged and still verified: CI on b7cffa0ec is 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green; the PR is non-draft; Clause-②: no so no carriers are owed; and there is no merge conflict — the set of files changed by both origin/main and this PR since their merge-base is empty, measured rather than assumed.

Also unchanged: ⛔ no empty commit, ⛔ no close-and-reopen, ⛔ no merge commit for a conflict that does not exist.

Next action is to re-read the queue and merged_at and, if it is genuinely out of the queue with a computed mergeable state, arm once against that state — which is a response to a dequeue, not a third retry of an arm that in fact succeeded.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-automation: resolveTriggerBinding keeps a private copy of the trigger-kind chain that spec's resolveFlowTriggerKind now mirrors

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(service-automation): take the trigger kind from spec's shared resolver - #14994

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver
Sep 3, 2026
Merged

refactor(service-automation): take the trigger kind from spec's shared resolver#14994
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14328

AutomationEngine.resolveTriggerBinding kept a private copy of the chain that decides which trigger kind a flow asks for, in parallel with @objectstack/spec's resolveFlowTriggerKind — the authoring-time mirror of the same rule that defineStack's trigger-capability refusal and @objectstack/lint's validate-flow-trigger-readiness already read. Nothing pinned the two copies together, so a branch added to one side would leave defineStack accepting a stack the runtime leaves inert, or refusing one it would arm.

The shape implemented

resolveTriggerBinding now takes its kind from resolveFlowTriggerKind(flow) and keeps only the per-kind binding construction — which start-node fields each trigger needs. getTriggerBindingAudit and the boot banner therefore name the kind authoring named, by construction.

resolveFlowTriggerKind is one more name on an import statement engine.ts already had (@objectstack/spec/automation); @objectstack/spec is a real dependencies entry of service-automation, so there is no new package edge.

Two guards close the drift the card is about, one static and one at run time:

  • the per-kind switch is exhaustive over FlowTriggerKind with a never default, so a kind added to spec fails this package's type-check until its binding shape is written. At run time (a spec build ahead of this one) it falls back to today's behaviour — no binding — rather than throwing inside the boot audit;
  • a new case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. That is the case the ablation shows is the actual coupling measurement.

The array-form divergence, and how it is preserved

The ARRAY form of triggerType (['record-after-create', 'record-after-delete']) resolves to no kind in spec, deliberately, and the resolver's own header says why: multi-event unions are unsupported (#3457), and reading the shape as "asks for a record-change trigger" would have defineStack demand a capability the flow can never use and would widen the lint rule's auto-triggered set. The engine routes it to the record-change trigger anyway — a diagnostic route — so that trigger refuses it loudly at bind time (#3481) instead of the flow folding into "manual" and vanishing from every surface.

It is kept as an explicit pre-check BEFORE the resolver call, never folded into it. The ordering is load-bearing on its own: array form outranks timeRelative, which the resolver — blind to the array — would otherwise answer for a start node carrying both. Moving the pre-check after the resolver call silently re-routes that flow and swaps the loud refusal for a sweep, so it has its own pin.

Clause-②: no

Re-derived from this diff, not inherited. No exported symbol is added or removed (resolveTriggerBinding is private; resolveFlowTriggerKind is imported, not re-exported), no payload key changes, and no accept-set moves.

The one place the seat named as able to flip it — whether the publicgetTriggerBindingAudit or the boot banner reports anything different for any flow, the array-form case being the candidate — was measured rather than argued, and it does not flip:

  • Ablation Leg A reverted the unification wholesale (engine.ts → the merge base) with the new pins in place. Nothing reddened: 19/19 service-automation cases and 3/3 trigger-record-change cases green. Old chain and new produce the same answer on every case the pins cover, array form included.
  • The array-form route is unchanged in both fields the refusal is built from: event is still the joined token that maps to no hook, config.triggerType still carries the raw array. Pinned directly.
  • 1,223 existing service-automation cases and 81 trigger-record-change cases pass unchanged.

Pins, and what each one catches

pincatches
7 precedence cases in flow-trigger-kind-shared-resolver.test.ts, each asserting a literal kind through the real engine (registerFlowgetFlowRuntimeStates())a unification that mis-maps a case. The literal is the half that can fail alone — expect(engineKind).toBe(resolveFlowTriggerKind(flow)) on its own is satisfied by two wrong answers that agree, and by undefined === undefined
the same cases' second half, against resolveFlowTriggerKind on the flow the engine storeda registration that rewrote the start node under the literal
names the same kind on getTriggerBindingAuditthe card's stated payoff, on the public surface the boot banner prints
reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDSthe coupling itself — a kind added to spec that the engine has no binding shape for. Reads only engine output, which is what makes it the one case that discriminates coupled from merely-agreeing (see Leg C)
4 array-form cases (routes to record_change though spec answers no kind; pre-check outranks timeRelative; raw array + joined event forwarded; an array with no record-* element is left to the resolver)"unifying" the divergence away, and re-ordering the pre-check behind the resolver
array-form-refusal-end-to-end.test.ts (new, in @objectstack/trigger-record-change)the refusal itself — engine → real RecordChangeTrigger → the warn, asserted by flow name, "ARRAY", "NOT bound / never fire", the record-after-write steer and #3457, plus zero hooks registered. Its third case is an anti-vacuity control: a legitimate single record-after-update token draws no warn and arms afterUpdate

The e2e file lives in trigger-record-change because that is the only side of the edge where both halves exist: @objectstack/service-automation is a devDependency there, and the trigger is deliberately not a dependency of the engine. Same split — and same dist/-resolution note — as the existing reentrant-start-condition.test.ts.

Ablation

Directions were written down before any leg ran. Resolution facts that shape the legs, measured not assumed: service-automation's own tests read engine.ts from source and @objectstack/spec through exports to dist/; trigger-record-change's tests read @objectstack/service-automation through exports to dist/ (both from check:test-source-alias's KNOWN_UNALIASED_TEST_IMPORTS). @objectstack/spec is external in the engine's bundle, so rebuilding spec alone reaches the engine's dist. Every leg rebuilt what it mutated; the driver carried trap … EXIT INT TERM with absolute paths.

legmutationpredictedmeasured
Arevert the unification (engine.ts → merge base)nothing reddens — a behaviour-preserving refactor cannot redden behaviour pins; stated in advance as not discriminating✅ as predicted. 19/19 + 3/3 green
Bkeep the unification, delete the array-form pre-check (the "unify it away" mistake)red: 3 array cases here + the existing trigger-dispatch-observability array case + 2 of 3 e2e cases; green: all 7 precedence cases, the reachability case, and the e2e anti-vacuity case✅ exactly. 4 failed | 15 passed in service-automation, 2 failed | 1 passed e2e. Reddened: routes array form to record_change…, keeps the array pre-check AHEAD of the resolver…, hands the record-change trigger the raw array…, hands an array triggerType to the record_change trigger…, refuses the array form by name…, gets there because the ENGINE routed it…
C1spec's resolver loses its api branch, spec rebuilt; unified enginered on the api cases only3 failed | 11 passed: both api precedence cases (failing at line 134, the engine half) and reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS (line 189)
C2same mutated spec, old private chain❌ I predicted fully green; it was 2 failed | 17 passedthe two api cases redden here too — but at line 139, the half that queries resolveFlowTriggerKinddirectly. Line 134, the engine half, stayed green, and the reachability case stayed green

C1 vs C2 is the leg that measures what this card buys, and the line numbers are the measurement. With the unification, a spec-side change moves the engine's answer (line 134 reddens, reachability reddens). Without it, the engine is untouched by the same spec change (line 134 green, reachability green) and only the direct spec query reddens. That is the drift the card describes, demonstrated in both directions.

My C2 prediction was wrong and the reason is worth recording rather than smoothing over: a pin's "and to what spec answers" half asks spec directly, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The coupling measurements are the engine-half literal and the reachability case. Neither leg reddened everything.

Mutation-reached-disk proof. Literal-text counts before/after on the mutated file each time (resolveFlowTriggerKind 2 → 0; Array.isArray(config.triggerType) 1 → 0; f.type === 'api' 1 → 0), each aborting the leg on a miss. One marker was initially vacuous and is reported as such: scripts/ablation-dist-preflight.mjs with the single-quoted source spelling f.type === 'api' matched only sourcemaps, because the bundler re-quotes to double quotes — the tool caught it itself (✗ … found ONLY in 6 sourcemap files … Treat this run as void). Leg C was re-run with the bundling-stable marker triggerType === "api") return "api": ✓ marker present in 6 built files before, ✓ marker absent from all 215 built files after the mutated rebuild, ✓ present again after the restore rebuild, and the same 3-case red set reproduced.

Restore proof.git hash-object equal to the HEAD blob for both mutated files (engine.ts00401ee1…, flow-trigger-kind.ts2e7c8137…), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on the merged head (origin/main merged in first — the earlier derivation warned STALE TREE and named the two files it read stale copies of, so the list below is the one derived from a tree someone is on). Every exit code captured by redirect-then-read, never across a pipe.

41 commands, all captured by redirect-then-read. 38 exited 0; 3 exited 3 and are NOT MEASURED — neither green nor red — quoted below from each gate's own verdict text.

Exit 0 (38):node scripts/check-adr-0087-registration.mjs · node scripts/check-changeset-no-major.mjs · node scripts/check-ci-filter-parity.mjs · node scripts/check-closing-keyword-parity.mjs · node scripts/check-comment-mask-adoption.mjs · node scripts/check-comment-mask-corpus.mjs · node scripts/check-cross-package-test-inputs.mjs · node scripts/check-empty-changeset.mjs · node scripts/check-keyed-text-bounds.mjs · node scripts/check-plugin-teardown-shape.mjs · node scripts/check-shard-attestation.mjs · node scripts/check-system-context-census.mjs · node scripts/check-tenant-audit-census.mjs · node scripts/check-undeclared-dep-imports.mjs · node scripts/docs-audit/check-affected-docs.mjs · node scripts/docs-audit/check-drift-comment.mjs · node scripts/pm/check-half-states.mjs · node scripts/pm/release-rehearsal-clone.mjs --self-test · pnpm check:changeset-gate-self-tests · pnpm check:cross-package-test-inputs · pnpm check:dispatcher-error-vocabulary · pnpm check:doc-authoring · pnpm check:engine-double-contract · pnpm check:logger-receiver-detach · pnpm check:nul-bytes · pnpm check:objectql-double-limit · pnpm check:objectui-changeset · pnpm check:page-declaration-shape · pnpm check:pm-half-states · pnpm check:published-files · pnpm check:query-options-erasure · pnpm check:refd-timer-probe · pnpm check:slot-lookup · pnpm check:test-source-alias · pnpm check:type-check-coverage · pnpm check:type-source-resolution · pnpm check:watch-hint-literal · pnpm check:where-matcher

NOT MEASURED (exit 3, 3):

  • node scripts/check-test-completeness.mjs — exit 3

Nothing was measured: this gate exited before parsing a single summary line, so
this result says NOTHING about whether every test vitest counted actually ran,
nor about whether every scheduled package reported.
⛔ It is NOT a finding, and it is not evidence that anything in the tree is wrong.

It wants a saved turbo run test log; running the family locally, its own fix line says to "record this gate as NOT MEASURED".

  • pnpm check:dual-build-cjs-loads — exit 3

PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.
· @objectstack/hono#. -> ./dist/index.js (no packages/adapters/hono/dist)
… 40 more
Run pnpm build first. ⛔ This is NOT a pass: nothing was measured.

Its own self-test passed first (✓ check-dual-build-cjs-loads self-test: 93 cases pass); only the repo-wide built-output leg is unmet. Needs a whole-repo pnpm build, which is CI's run, not mine.

  • pnpm check:type-check-debt — exit 3

⛔ This is NOT a pass and NOT a finding: nothing was measured, so this run says
NOTHING about whether any DEBT or TEST_DEBT number is still correct.

Same cause — it asks for the closure build lint.yml does first (pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'). Note pnpm check:type-check-coverage exited 0 beside it.

Two notes on the derived list rather than trusting it blind: dispatch-gates.mjs keys entries by script path, so a script CI invokes twice collapses to one entry; and the derived list excludes CI's always-runs tail. Re-derived after the merge commit that changed the file set, and the union above was run on the final head 39fcadf65 — same tree as the suite run below.

docs-drift-check disposition — 11 pages opened, zero doc edits, and why

The bot is advisory, so this is the disposition rather than a skip. Its hypothesis — that nothing needs an edit because the refactor is behaviour-neutral — was tested, not inherited: ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can be describing behaviour this PR moved. The bot also discloses that it computed on merge commit f8f059e9a631576a850615bf7116350bba098063 rather than this PR head, and that a page stating the rule by its inputs shares no identifier with the emitter and cannot be listed at all — so the list was treated as a starting point, not the population.

Its 10 hand-written pages. All flagged through the record_change / time_relative string literals in resolveTriggerBinding, which this PR neither renames nor re-values.

pagewhy no edit
automation/flows.mdx (flagged twice, via time_relative too)Three passages read. :1411 "Registration is not arming … defineStack refuses a stack … naming the flow, the trigger kind it resolved" describes the authoring side, which already read resolveFlowTriggerKind before this PR. :1818 the single-event triggerType table — every token still resolves to record_change. :1742 the time-relative sweep, shown with a schedule cadence besidetimeRelative: that is precisely the precedence case, and it is preserved (the resolver ranks time_relative ahead of schedule for the same reason the private chain did). This PR makes that documented ordering pinned rather than incidental
permissions/capabilities.mdx (flagged twice, via time_relative too):28 lists record_change / schedule / time_relative / api as the flow kinds needing triggers. That set is FLOW_TRIGGER_KINDS, unchanged — and now asserted reachable through the real engine by a new case
automation/approvals.mdx, automation/workflows.mdx, concepts/architecture.mdx, getting-started/common-patterns.mdxAuthoring examples: type: 'record_change' flows with single-string triggerType tokens. Same kind, same binding, same fields
automation/hooks.mdxThe hooks-vs-flows chooser, naming record_change and schedule/timeRelative as options. Unchanged
api/plugin-endpoints.mdx, kernel/services-checklist.mdx, protocol/objectql/schema.mdxSingle passing mentions of record_change as a concept

The blind spot the bot warned about, checked. Searching the docs tree for the rule stated by its inputs (timeRelative, array-form / multi-event prose) surfaced one page not on the list: content/docs/references/automation/time-relative-trigger.mdx. It describes config.timeRelative with an optional schedule cadence — accurate under this PR, and in any case it is marked ⚠️ AUTO-GENERATED — DO NOT EDIT, generated from packages/spec/src/automation/time-relative-trigger.zod.ts, which this PR must not touch. No page anywhere in content/docs/ describes array-form triggerType handling, and none mentions getTriggerBindingAudit or the boot banner's contents, so the two places the coordinator named as able to falsify the hypothesis have no documentation to go stale.

The 3 release-owned pages: read, not touched, and none is wrong.releases/v17.mdx:1536 states "array-form triggerType fails loudly instead of silently never firing" — that is exactly the behaviour this PR preserves as an explicit pre-check, so it remains correct. releases/v16.mdx:393 (the time_relative trigger) and :409 (the kernel:bootstrapped binding audit warning per enabled-but-unbound flow) and releases/v12.mdx:159 are all still accurate. Nothing to file.

Patch round 1 — the type-check DEBT lane was red, and it was mine

Two red checks, one root cause.Type Check · debt ledger failed on 39fcadf65 with @objectstack/service-automation: DEBT records 3 raw tsc error(s), tsc --noEmit now reports 4 (+1), and TypeScript Type Check was red only because it aggregates that lane (its other three — typecheck-source-gates, typecheck-workspace, typecheck-consumers — were green on the same head). One fix clears both.

Measured, not guessed. Built the closure, then ran the package's tsc --noEmit directly:

src/flow-trigger-kind-shared-resolver.test.ts(250,21): error TS7006: Parameter 'binding' implicitly has an 'any' type.
src/nested-region-parity.test.ts(95,25): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(151,20): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(180,20): error TS2341: Property 'flows' is private …

The three TS2341 are the ledger's recorded pile, at exactly the lines its note itemises (95/151/180). The +1 was mine, in the new pin file — consistent with the aggregator's narrowing, since this package has no typecheck script, so its tsc --noEmit runs only in the debt lane.

The cause. The recording trigger double was an object literal cast as never. The cast erases the contextual type for start, leaving binding with nothing to infer from.

The fix is a type fix. The double is now typed as the real FlowTrigger, the capture array as FlowTriggerBinding[] — the shape engine.test.ts already uses — and the cast is gone. Both assertion lines in that case are byte-identical; git diff touches no line containing expect. What the pin checks, and the array-form divergence it guards, are unchanged.

Proof.

tsc --noEmit (packages/services/service-automation) → 3 errors, exit 2
the ledger's 3 exactly, same composition (TS2341 ×3, nested-region-parity.test.ts 95/151/180)
pnpm check:type-check-debt → EXIT=0 (captured by redirect-then-read)
✓ check:type-check-coverage --self-test — 48 semantic case(s) + 68 observation case(s)
+ 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s)
+ 18 exit-code case(s) hold.
check-type-check-coverage --re-measure: OK — 18 ledger entr(ies) re-measured in 192.9s,
218 raw tsc error(s) total, none above its recorded number.

⛔ The ledger entry was not raised, nothing was @ts-expect-error-ed, no tsconfig loosened, no test skipped. @objectstack/trigger-record-change's own typecheck (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json) also exits 0.

Why the local run could not have caught it. This is the same gate the original PR body records as exit 3 / NOT MEASURED (PREREQUISITE NOT MET … nothing was measured) — an honest reading for a container with no whole-repo dist/. CI, with a full build, was the first place it actually ran. Recorded as a lesson: a NOT MEASURED gate is not a quiet pass, and one your diff can move is the one most likely to surprise you in CI.

Re-verification on the new head b7cffa0ec (supersedes the two sections above)

origin/main was merged again first — dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself among the files it had read stale copies of, which is the gate at issue. (Those upstream ledger edits are @objectstack/metadata graduations; the service-automation entry is untouched.)

  • Gate union re-derived and re-run in full: 41/41, 40 exit 0, 1 NOT MEASURED. With a whole-repo build present, check:dual-build-cjs-loads and check:type-check-debt now both exit 0 — they were the NOT MEASURED entries in the original run. The single remaining NOT MEASURED is node scripts/check-test-completeness.mjs (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.
  • Suites green, unchanged:service-automationTest Files 103 passed (103) / Tests 1223 passed (1223); trigger-record-changeTest Files 8 passed (8) / Tests 81 passed (81).

Clause-②: no — unchanged and unaffected: a test-file type annotation adds no exported symbol, no payload key and no accept-set change.

Deliberately not done

  • No packages/spec edit. It is single-owner and this PR is a consumer of resolveFlowTriggerKind. The change needed none — the temporary spec mutation in Leg C was an ablation, restored with hash proof and never committed.
  • The array-form divergence was not unified, per the card, the triage note and the resolver's own header. It stays a pre-check with its own pins on both sides.
  • No content/docs/releases/** edit. The release-notes input is the changeset.
  • No behaviour change was smuggled in. Leg A is the evidence, not the claim.

Verification, on the final head 39fcadf65

pnpm --filter '@objectstack/service-automation^...' build # closure first: spec resolves to dist/ here
pnpm --filter '@objectstack/service-automation' --filter '@objectstack/trigger-record-change' test
service-automation Test Files 103 passed (103) Tests 1223 passed (1223)
trigger-record-change Test Files 8 passed (8) Tests 81 passed (81)
os-verify-lock: VERDICT command-exit 0

Clause-②: no

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…d resolver (#14328)
`AutomationEngine.resolveTriggerBinding` kept a private copy of the chain that
decides which trigger kind a flow asks for, in parallel with
`@objectstack/spec`'s `resolveFlowTriggerKind` — the authoring-time mirror that
`defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
`validate-flow-trigger-readiness` already read. Nothing pinned the copies
together. The engine now takes the kind from the shared resolver and keeps only
the per-kind binding construction.
The array-form `triggerType` divergence is PRESERVED as an explicit pre-check
before the resolver call: spec answers no kind for it on purpose, and the engine
routes it to the record-change trigger only so that trigger can refuse it loudly
at bind time (#3457/#3481). Its ordering is load-bearing — array form outranks
`timeRelative`, which the resolver would otherwise answer for a start node
carrying both.
Two new guards close the drift: the per-kind switch is exhaustive over
`FlowTriggerKind` with a `never` default, so a kind added to spec fails this
package's type-check; and new pins assert every kind in `FLOW_TRIGGER_KINDS` is
reachable through the real engine, plus the preserved divergence end-to-end
against the real record-change trigger's refusal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 4 documentable anchor(s).

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

  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/permissions/capabilities.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in resolveTriggerBinding))

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

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v16.mdx(via AutomationEngine (symbol, a top-level class), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v17.mdx(via AutomationEngine (symbol, a top-level class), record_change (literal, a string literal in resolveTriggerBinding))

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

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 5 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 55eac1e1304fc187d837e69a0ef459422904a0c3packageMentionDocs.

Which tree this was computed on

This run read content/docs from b752853db28bfead3687cd69fe5117cc5f415669 — the merge of head b7cffa0ec82e354157d793827dd991f3eb3cbe52 into base 55eac1e1304fc187d837e69a0ef459422904a0c3, 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 b752853db28bfead3687cd69fe5117cc5f415669 && git checkout b752853db28bfead3687cd69fe5117cc5f415669
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 55eac1e1304fc187d837e69a0ef459422904a0c3 b7cffa0ec82e354157d793827dd991f3eb3cbe52 && git checkout -B drift-repro 55eac1e1304fc187d837e69a0ef459422904a0c3 && git merge --no-ff b7cffa0ec82e354157d793827dd991f3eb3cbe52
node scripts/docs-audit/affected-docs.mjs --json 55eac1e1304fc187d837e69a0ef459422904a0c3

⚠️ 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 55eac1e1304fc187d837e69a0ef459422904a0c3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

{
"issue": 14328,
"status": "done",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"premise_still_valid": true,
"summary": "AutomationEngine.resolveTriggerBinding now takes its trigger KIND from spec's resolveFlowTriggerKind and keeps only the per-kind binding construction; the array-form triggerType divergence is PRESERVED as an explicit pre-check BEFORE the resolver call, and its ordering is pinned (array form outranks timeRelative, which the resolver would otherwise answer for a start node carrying both). Two drift guards added: the per-kind switch is exhaustive over FlowTriggerKind with a never default (a kind added to spec fails this package's type-check), and a case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. Clause-2 re-derived from my own diff as 'no' and stated on its own line in the PR body: no exported symbol, no payload key, no accept-set change -- and measured, not argued, by ablation Leg A (reverting the unification reddened NOTHING, so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included). All five falsifiers checked and none fired: no spec edit needed; resolveFlowTriggerKind confirmed importable from the built @objectstack/spec/automation subpath; the two chains agree on every string-form flow (Leg A is the measurement); the array divergence is fully compatible with taking the kind from the resolver; nobody had wired it in. Three process notes, reported rather than smoothed: (1) I read AGENTS.md AFTER my first file edit, not before -- nothing in it was violated, but the ordering was wrong; (2) my ablation Leg C2 prediction was WRONG and I report it as such, with the diagnosis (see 'tests'); (3) the dispatch said to post this report as a PR comment while the standing os-dev contract says the card comment is the authoritative copy, so I posted it to BOTH (issue 14328 and PR 14994) rather than silently picking a side. Merged origin/main into the branch before deriving gates, because dispatch-gates.mjs reported STALE TREE and named the two files it had read stale copies of.",
"tests": "All on final head 39fcadf65, every exit code captured by redirect-then-read (never across a pipe).\nSUITES: pnpm --filter '@objectstack/service-automation^...' build (closure first -- service-automation resolves @objectstack/spec through exports to dist/), then pnpm --filter service-automation --filter trigger-record-change test => 'Test Files 103 passed (103) / Tests 1223 passed (1223)' and 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNEW PINS: 14 cases in packages/services/service-automation/src/flow-trigger-kind-shared-resolver.test.ts (7 precedence cases each asserting a LITERAL kind through the real engine, plus the audit surface and a FLOW_TRIGGER_KINDS reachability case), and 3 in packages/triggers/trigger-record-change/src/array-form-refusal-end-to-end.test.ts (engine -> real RecordChangeTrigger -> the refusal asserted by flow name, 'ARRAY', 'NOT bound / never fire', the record-after-write steer and the 3457 citation, zero hooks registered, plus an anti-vacuity control).\nGATES: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands => 41 commands, re-derived after the merge commit. 38 exited 0. 3 exited 3 and are NOT MEASURED, quoted from their own verdict text in the PR body: check-test-completeness ('Nothing was measured: this gate exited before parsing a single summary line ... It is NOT a finding'), check:dual-build-cjs-loads ('PREREQUISITE NOT MET -- this gate reads built output, and some package has no dist/ ... This is NOT a pass: nothing was measured'; its own self-test passed first, 93 cases), check:type-check-debt ('This is NOT a pass and NOT a finding: nothing was measured'; check:type-check-coverage exited 0 beside it). No gate exited 1.\nABLATION -- 4 legs, directions written down BEFORE running, driver carried trap ... EXIT INT TERM with absolute paths, and each leg rebuilt what it mutated:\n LEG A (revert the unification, engine.ts -> merge base): predicted NOTHING reddens and said in advance that this leg does NOT discriminate. Measured: 19/19 + 3/3 green, as predicted. This is the Clause-2 measurement.\n LEG B (delete the array-form pre-check): predicted a SPECIFIC red set. Measured exactly it -- '4 failed | 15 passed' in service-automation (3 of my array cases + the existing trigger-dispatch-observability array case) and '2 failed | 1 passed' end-to-end; all 7 precedence cases, the reachability case and the anti-vacuity control stayed GREEN. Discriminating.\n LEG C1 (spec's resolver loses its api branch, spec rebuilt; unified engine): predicted red on the api cases only. Measured '3 failed | 11 passed' -- both api precedence cases failing at test line 134 (the ENGINE half) plus 'reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS' at line 189.\n LEG C2 (same mutated spec, OLD private chain): I predicted FULLY GREEN and I WAS WRONG -- measured '2 failed | 17 passed'. Diagnosis: the two api cases redden at line 139, the half that queries resolveFlowTriggerKind DIRECTLY, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The engine half (line 134) and the reachability case both stayed GREEN. C1-vs-C2 is therefore still the leg that measures what this card buys, and the LINE NUMBERS are the measurement: with the unification a spec-side change moves the engine's answer; without it the engine is untouched. No leg reddened everything.\nMUTATION-REACHED-DISK: literal-text counts before/after on the mutated file each leg, each aborting on a miss -- resolveFlowTriggerKind 2 to 0, Array.isArray(config.triggerType) 1 to 0, f.type === 'api' 1 to 0. ONE MARKER WAS VACUOUS AND I REPORT IT: ablation-dist-preflight.mjs with the single-quoted SOURCE spelling matched only sourcemaps, because the bundler re-quotes to double quotes -- the tool caught it itself ('marker found ONLY in 6 sourcemap files ... Treat this run as void'). Leg C was RE-RUN with the bundling-stable marker 'triggerType === \"api\") return \"api\"': present in 6 built files before, 'absent from all 215 built files' after the mutated rebuild, present again after the restore rebuild, and the same 3-case red set reproduced.\nRESTORE PROOF: git hash-object equal to the HEAD blob for both mutated files (engine.ts 00401ee1..., flow-trigger-kind.ts 2e7c8137...), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree. The temporary packages/spec mutation was an ablation only -- never committed; the shipped diff touches no spec file.",
"mcp_calls": "0 -- REST via container curl for every GitHub read and write (probe returned 200); no MCP GitHub call was made this run",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

PM ACCEPT — domain:services execution seat

Session session_01AUF1NoViznQK32gqpK8wS8, reviewer of record. Verified against the tree at head 39fcadf65, ⛔ not against the report — the report is the thing under review, not evidence for it.

The load-bearing claim, re-read at the tree

The one way this card could have been got wrong was unifying the array-form divergence away. It was not:

  • the array-form pre-check block ends at engine.ts relative line 54; const kind = resolveFlowTriggerKind(flow); is at relative line 56. The pre-check genuinely precedes the resolver.
  • resolveTriggerBinding is still private; switch (kind) carries default: { const unhandledKind: never = kind; }, so a kind added to spec fails this package's type-check rather than falling through silently.
  • git diff --name-only origin/main...HEAD touches nopackages/spec/** and nocontent/docs/releases/**. Four files, +584/−56.

⭐ The in-code comment states the reason better than my dispatch order did, and the improvement is substantive rather than editorial. I said "preserve the divergence so the refusal stays loud." The dev's comment adds why the resolver must not answer for it at all: array form resolves to no kind on purpose, because reading it as "asks for a record-change trigger" would make defineStack demand a capability for a flow that can never use it, and would widen the lint rule's auto-triggered set. It also names a consequence I had not: the pre-check ordering preserves this method's own precedence — array form outranks timeRelative/schedule, which the resolver, blind to array form, would otherwise answer for a start node carrying both.

Clause-② no — accepted, and accepted because it is MEASURED

The declaration is re-derived from the dev's own diff rather than inherited from my claim comment, which is what I asked for. More importantly it is measured, not argued: Ablation Leg A reverted the unification and reddened nothing — so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included. That is the correct instrument for Clause-②: if reverting the change is unobservable, the change widens no accept set. No exported symbol, no payload key, resolveTriggerBinding still private. No contract-review tier is owed.

⭐ The wrong prediction, and why reporting it was the right call

Leg C2 was predicted fully green and measured 2 failed | 17 passed. The dev reported that as a wrong prediction rather than quietly rewriting the prediction to match, and diagnosed it correctly: the two api cases redden at test line 139, the half that queries resolveFlowTriggerKinddirectly, so that half reddens on any spec mutation regardless of the engine and cannot measure coupling. The engine half (line 134) and the reachability case stayed green.

The diagnosis is right, and the conclusion drawn from it is the useful one: C1-vs-C2 is still the leg that measures what this card buys, and the line numbers are the measurement — with the unification a spec-side change moves the engine's answer; without it the engine is untouched. A dev that had silently adjusted its prediction would have produced a tidier report and a worse one.

Two more disclosures I want on the record as good practice, not as faults: the vacuous marker (ablation-dist-preflight matched only sourcemaps because the bundler re-quotes single to double quotes) was caught by the tool, declared void, and the leg re-run with a bundling-stable marker — present in 6 built files before, absent from all 215 after, present again after restore. And the AGENTS.md read-ordering slip was self-reported though nothing in it was violated.

Landing state

Not armed yet. CI on 39fcadf65 reads 30 runs: 10 success, 3 skipped, 17 still in progress, zero failures. In-progress is not green, and "no failures" on a partially-run head is the same reading a head with no runs at all would give. Auto-merge goes on when the job-level read comes back complete with nothing red — not before.

pm:dispatched comes off card #14328 on MERGED.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

docs-drift-check disposition — 11 pages opened, zero doc edits. Recorded in full in the PR body (section "docs-drift-check disposition"); this comment is the thread-visible pointer, not a second os-dev-report.

Short form: the bot's hypothesis was tested rather than inherited — ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can describe behaviour this PR moved. All 10 hand-written pages were flagged through the record_change / time_relative string literals, which this PR neither renames nor re-values.

The two places named as able to falsify it have no documentation to go stale: no page in content/docs/ describes array-form triggerType handling, and none states what getTriggerBindingAudit or the boot banner report. Following the bot's own warning about pages that state a rule by its inputs, one unlisted page was found and read — content/docs/references/automation/time-relative-trigger.mdx — which is accurate here and is in any case marked AUTO-GENERATED — DO NOT EDIT from a packages/spec source this PR must not touch.

The 3 release-owned pages were read, not touched, and none is wrong — notably releases/v17.mdx:1536 ("array-form triggerType fails loudly instead of silently never firing") states exactly the behaviour this PR preserves as an explicit pre-check before the shared resolver. Nothing to file.

Generated by Claude Code


Generated by Claude Code

…tead of casting it away (#14328)
The type-check DEBT lane went red on the previous head: `@objectstack/service-automation`
measured 4 raw `tsc --noEmit` errors against a ledger entry recording 3.
The +1 was mine and it was in the new pin file:
src/flow-trigger-kind-shared-resolver.test.ts(250,21):
error TS7006: Parameter 'binding' implicitly has an 'any' type.
The recording trigger double was written as an object literal cast `as never`.
The cast erases the contextual type for `start`, so `binding` had no type to be
inferred from. Fixed by typing the double as the real `FlowTrigger` and the
capture array as `FlowTriggerBinding[]` — the same shape `engine.test.ts` already
uses — which restores the contextual type and removes the cast entirely.
This is a TYPE fix only. Both assertion lines in that case are byte-identical;
what the pin checks is unchanged, and the array-form divergence it guards is
untouched. The ledger entry is NOT raised: re-measured back to exactly the 3 it
records, same composition (TS2341 x3 in nested-region-parity.test.ts at
95/151/180, the pre-existing private-`flows` reads).
This package has no `typecheck` script, so its `tsc --noEmit` runs only in the
debt lane — which is why the error was invisible to the local gate run, where
that gate honestly reported PREREQUISITE NOT MET / nothing measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing conditions MET — held only by API quota, not by anything in this PR

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Recording state durably so it survives this session and so the next reader does not re-derive it.

CI on 4dd73b545 is fully green, read at job level rather than from an aggregate: 33 check runs — 30 success, 3 skipped, 0 pending, nothing not-green. Both checks that were red on the previous head now pass:

checkon 39fcadf65on 4dd73b545
Type Check · debt ledgerfailuresuccess
TypeScript Type Checkfailuresuccess
Lint & Repo Gatessuccess

mergeable_state: clean.

The patch round, verified at the tree

The two reds were one root cause: TypeScript Type Check is an aggregator, and three of its four lanes (typecheck-source-gates, typecheck-workspace, typecheck-consumers) passed on the red head — it was red purely because it re-reports typecheck-debt.

The fix (4dd73b545, one file, +11/−4) replaces an … as never cast — which erased the contextual type and left a callback parameter implicitly any (TS7006) — with a properly typed const trigger: FlowTrigger. Verified by reading the diff, not the report:

  • started is typed FlowTriggerBinding[] instead of a loose inline shape — stronger, not weaker;
  • the binding as (typeof started)[number] cast is removed, not added;
  • no assertion changedexpect(started).toHaveLength(1) and everything below it is untouched.

⛔ Nothing was ledgered. The shrink-only ratchet was not raised, and no @ts-expect-error was used — the gate's own text forecloses both, and the author's remedy was taken instead.

⛔ What is blocking, and it is not this PR

The two remaining actions — mark ready for review, then enable auto-merge (squash) — are both GraphQL-only operations on GitHub. This session's GraphQL endpoint serves only a pinned set of PR-review operations ("This GraphQL query is not enabled for this session"), and REST's PATCH /pulls/:n silently ignores draft (returns 200 with draft unchanged — verified, not assumed). So both steps require the MCP GitHub tools, and that pool is currently exhausted:

API rate limit already exceeded for user ID 319429713

Per this seat's standing rule the response is to back off rather than poll or loop-retry. The repo-scoped REST pool is separate and healthy (15000/15000), which is how this comment is being written — but it cannot serve the two remaining operations.

Order when quota returns, and the order matters: mark ready first, then arm auto-merge — auto-merge does not survive a draft conversion, so arming first would be undone by the undraft.

No carriers to clear: Clause-②: no, measured by ablation Leg A (reverting the unification reddened nothing), so no contract-review tier is owed. PM ACCEPT is comment 5529090987.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 3, 2026 17:51
@os-sales
os-sales added this pull request to the merge queueSep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

Patch round 1 -- supersedes the round-0 report above for gate/test readings; the implementation, pins and ablation are unchanged.

{
"issue": 14328,
"status": "done",
"round": "patch round 1",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"head": "b7cffa0ec",
"premise_still_valid": true,
"summary": "Patch round 1: the Type Check debt lane was red on 39fcadf65 and it was mine. Measured rather than guessed -- built the closure, ran the package's own tsc --noEmit, and got 4 errors: 3 TS2341 in src/nested-region-parity.test.ts at 95/151/180 (exactly the pile the ledger entry records and itemises) plus ONE new TS7006 at src/flow-trigger-kind-shared-resolver.test.ts(250,21), 'Parameter binding implicitly has an any type'. Cause: the recording trigger double was an object literal cast 'as never', and the cast erases the contextual type for start, so binding had nothing to infer from. Fixed as a TYPE fix only -- the double is now typed as the real FlowTrigger and the capture array as FlowTriggerBinding[], the same shape engine.test.ts already uses, and the cast is gone. BOTH assertion lines are byte-identical: git diff touches no line containing 'expect', so what the pin checks and the array-form divergence it guards are unchanged. The ledger entry was NOT raised, nothing was ts-expect-error'd, no tsconfig loosened, no test skipped. The second red check (TypeScript Type Check) was the aggregator re-reporting the same lane -- its other three lanes were green on that head -- so one fix cleared both, and the aggregator's narrowing was confirmed by measurement rather than taken on trust: this package has no typecheck script, so its tsc --noEmit runs only in the debt lane, which is exactly why the original local gate run honestly reported that gate as exit 3 / NOT MEASURED and could not have caught it. Merged origin/main again before re-verifying because dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself -- the gate at issue -- among the stale files; those upstream ledger edits are metadata graduations and the service-automation entry is untouched. Two observations for the seat, neither acted on: (1) the PR read back as draft:false after my body PATCH -- I did not set that and did not touch it, since REST PATCH /pulls does not accept 'draft' and I sent only 'body'; it is consistent with the coordinator's stated intent to do the undrafting and arming. (2) During the union re-run my first 'tail --pid' attached to a stale pid and reported the runner gone at 25/41 while the real runner was still holding the lock; I re-attached to the real pid and completed 41/41 rather than reporting the partial set.",
"tests": "All on final head b7cffa0ec, exit codes captured by redirect-then-read.\nTSC (the actual measurement): cd packages/services/service-automation && tsc --noEmit => exit 2, 3 errors, all TS2341 'Property flows is private' in src/nested-region-parity.test.ts at 95/151/180 -- the ledger's recorded number AND composition. Before the fix the same command reported 4, the extra being src/flow-trigger-kind-shared-resolver.test.ts(250,21) TS7006.\nDEBT GATE (first time it has actually run in this container -- it needed the whole-repo build that made it exit 3 before): pnpm check:type-check-debt => EXIT=0, both halves. Verdict text: '✓ check:type-check-coverage --self-test -- 48 semantic case(s) + 68 observation case(s) + 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s) + 18 exit-code case(s) hold.' and 'check-type-check-coverage --re-measure: OK -- 18 ledger entr(ies) re-measured in 192.9s, 218 raw tsc error(s) total, none above its recorded number.'\nSIBLING PACKAGE: pnpm --filter '@objectstack/trigger-record-change' typecheck => exit 0 (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json). NOTE: an earlier run of it showed TS2307 'Cannot find module @objectstack/service-automation' in two PRE-EXISTING files; that was an unmet prerequisite in a fresh worktree (service-automation had no built .d.ts because I had built only the '^...' closure, which excludes the package itself), not a finding -- building service-automation cleared it. Reported because it reads exactly like a real failure.\nGATE UNION re-derived and re-run in full on the final head: 41/41 recorded, 40 exit 0, 1 exit 3. With a whole-repo build present, check:dual-build-cjs-loads AND check:type-check-debt now both exit 0 -- they were two of the three NOT MEASURED entries in the pre-patch run. The single remaining NOT MEASURED is 'node scripts/check-test-completeness.mjs' (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.\nWHOLE-REPO BUILD: pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 => 71 successful, 71 total, exit 0 (run twice: once pre-merge, once post-merge).\nSUITES: service-automation 'Test Files 103 passed (103) / Tests 1223 passed (1223)'; trigger-record-change 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNO NEW ABLATION for this round: the change is a test-file type annotation with byte-identical assertions, so there is no behaviour to ablate. The original 4-leg ablation stands unchanged on the engine diff, which this round did not touch.",
"mcp_calls": "0 for this round as well -- REST via container curl for every GitHub read and write; still zero MCP GitHub calls across the whole card",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Blocked on enqueue — auto-merge accepts and does not persist. Stating it once.

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Head b7cffa0ec.

This PR is done on every axis I control. CI job-level on b7cffa0ec: 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green, Type Check · debt ledger and TypeScript Type Check both success. Non-draft. Clause-②: no, so no carriers are owed. PM ACCEPT is comment 5529090987; the patch round is verified at the tree (assertions byte-identical, ledger not raised).

What is actually blocking

Auto-merge does not persist. Twice now:

timeactionresult
17:51:34Zenable auto-mergeAPI reported enabled
17:54:04Zread backauto_merge: none, not in the queue
~17:55Zre-armed (once — the limit)API reported enabled
17:57:26Zread backauto_merge: none, not in the queue

Throughout, mergeable_state reads unknown and mergeable is null — GitHub is not returning a computed mergeability for this PR, and auto-merge does not survive that state.

⛔ It is NOT a merge conflict — measured, not assumed

merge-base(origin/main, PR head) → files changed by BOTH sides since it:
(empty)

The branch already merged origin/main at 77a532d67 (commit b7cffa0ec), and no file this PR touches is touched by anything that landed on main since. So there is nothing to resolve, and pushing a further merge commit would be a no-op that costs a full CI cycle.

The likeliest reading, offered as a hypothesis rather than a conclusion: main is advancing every few minutes while the merge queue drains (it moved fddfc8db0 → a4e4d2d78 inside this window, with 5 entries queued), and each advance re-invalidates the mergeability computation before auto-merge can act on it.

What I am doing about it

⛔ Not re-arming again — the standing rule is one re-arm, and a third would be the loop-retry the rule exists to prevent. ⛔ Not pushing an empty commit and ⛔ not closing/reopening to kick it; both are forbidden and neither addresses an uncomputed mergeability.

This PR stays watched with a check-in scheduled until it merges or the blocker changes. If mergeable_state resolves to clean and it is still unqueued, I arm again then — that is a different state, not a retry of this one.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

⛔ Correction to my comment above (5529902021) — the diagnosis was wrong

domain:services execution seat. This PR did enqueue. A pull_request.enqueued event fired at 17:52:29Z, so the arm at 17:51:34Z worked, and my headline "auto-merge accepts and does not persist" is false.

Two reading errors produced it, both mine:

  1. I read the queue 28 seconds too early. My git ls-remote snapshot was taken at 17:52:01Z; the enqueue happened at 17:52:29Z. I recorded "not in the queue" from a read that predates the event and then treated it as evidence about the arm.
  2. I misread auto_merge: none as the arm being dropped. GitHub clearsauto_merge when a PR enters the merge queue — the setting has done its job. So auto_merge: noneafter an enqueue is the expected state, not a failure. My table read that as the arm evaporating twice.

⚠️The second arm (~17:55Z) was therefore unnecessary, and I should not have made it: the PR was already queued when I sent it. It was harmless but it was based on the same misreading.

What the evidence actually supports

Enqueued 17:52:29Z; absent from the queue refs at 17:54:04Z, 17:57:26Z and 17:59:23Z; merged_at still null. So the real event is a dequeue without merging, some time between 17:52:29Z and 17:54:04Z — not a failure to arm. The removal notice GitHub sends for that is best-effort and did not arrive, which is why I only have the endpoints and not the cause.

⛔ I am not now asserting a cause for the dequeue. The candidates I can see — the queue's own CI run on the merged-with-main candidate, or an eviction when main advanced (fddfc8db0 → a4e4d2d78 inside this window) — are hypotheses, and I have already published one wrong diagnosis on this PR today. The next reading, not another guess, decides it.

What stands from that comment

Unchanged and still verified: CI on b7cffa0ec is 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green; the PR is non-draft; Clause-②: no so no carriers are owed; and there is no merge conflict — the set of files changed by both origin/main and this PR since their merge-base is empty, measured rather than assumed.

Also unchanged: ⛔ no empty commit, ⛔ no close-and-reopen, ⛔ no merge commit for a conflict that does not exist.

Next action is to re-read the queue and merged_at and, if it is genuinely out of the queue with a computed mergeable state, arm once against that state — which is a response to a dequeue, not a third retry of an arm that in fact succeeded.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-automation: resolveTriggerBinding keeps a private copy of the trigger-kind chain that spec's resolveFlowTriggerKind now mirrors

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

refactor(service-automation): take the trigger kind from spec's shared resolver - #14994

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver
Sep 3, 2026
Merged

refactor(service-automation): take the trigger kind from spec's shared resolver#14994
os-sales merged 4 commits into
mainfrom
claude/issue-14328-trigger-kind-resolver

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14328

AutomationEngine.resolveTriggerBinding kept a private copy of the chain that decides which trigger kind a flow asks for, in parallel with @objectstack/spec's resolveFlowTriggerKind — the authoring-time mirror of the same rule that defineStack's trigger-capability refusal and @objectstack/lint's validate-flow-trigger-readiness already read. Nothing pinned the two copies together, so a branch added to one side would leave defineStack accepting a stack the runtime leaves inert, or refusing one it would arm.

The shape implemented

resolveTriggerBinding now takes its kind from resolveFlowTriggerKind(flow) and keeps only the per-kind binding construction — which start-node fields each trigger needs. getTriggerBindingAudit and the boot banner therefore name the kind authoring named, by construction.

resolveFlowTriggerKind is one more name on an import statement engine.ts already had (@objectstack/spec/automation); @objectstack/spec is a real dependencies entry of service-automation, so there is no new package edge.

Two guards close the drift the card is about, one static and one at run time:

  • the per-kind switch is exhaustive over FlowTriggerKind with a never default, so a kind added to spec fails this package's type-check until its binding shape is written. At run time (a spec build ahead of this one) it falls back to today's behaviour — no binding — rather than throwing inside the boot audit;
  • a new case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. That is the case the ablation shows is the actual coupling measurement.

The array-form divergence, and how it is preserved

The ARRAY form of triggerType (['record-after-create', 'record-after-delete']) resolves to no kind in spec, deliberately, and the resolver's own header says why: multi-event unions are unsupported (#3457), and reading the shape as "asks for a record-change trigger" would have defineStack demand a capability the flow can never use and would widen the lint rule's auto-triggered set. The engine routes it to the record-change trigger anyway — a diagnostic route — so that trigger refuses it loudly at bind time (#3481) instead of the flow folding into "manual" and vanishing from every surface.

It is kept as an explicit pre-check BEFORE the resolver call, never folded into it. The ordering is load-bearing on its own: array form outranks timeRelative, which the resolver — blind to the array — would otherwise answer for a start node carrying both. Moving the pre-check after the resolver call silently re-routes that flow and swaps the loud refusal for a sweep, so it has its own pin.

Clause-②: no

Re-derived from this diff, not inherited. No exported symbol is added or removed (resolveTriggerBinding is private; resolveFlowTriggerKind is imported, not re-exported), no payload key changes, and no accept-set moves.

The one place the seat named as able to flip it — whether the publicgetTriggerBindingAudit or the boot banner reports anything different for any flow, the array-form case being the candidate — was measured rather than argued, and it does not flip:

  • Ablation Leg A reverted the unification wholesale (engine.ts → the merge base) with the new pins in place. Nothing reddened: 19/19 service-automation cases and 3/3 trigger-record-change cases green. Old chain and new produce the same answer on every case the pins cover, array form included.
  • The array-form route is unchanged in both fields the refusal is built from: event is still the joined token that maps to no hook, config.triggerType still carries the raw array. Pinned directly.
  • 1,223 existing service-automation cases and 81 trigger-record-change cases pass unchanged.

Pins, and what each one catches

pincatches
7 precedence cases in flow-trigger-kind-shared-resolver.test.ts, each asserting a literal kind through the real engine (registerFlowgetFlowRuntimeStates())a unification that mis-maps a case. The literal is the half that can fail alone — expect(engineKind).toBe(resolveFlowTriggerKind(flow)) on its own is satisfied by two wrong answers that agree, and by undefined === undefined
the same cases' second half, against resolveFlowTriggerKind on the flow the engine storeda registration that rewrote the start node under the literal
names the same kind on getTriggerBindingAuditthe card's stated payoff, on the public surface the boot banner prints
reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDSthe coupling itself — a kind added to spec that the engine has no binding shape for. Reads only engine output, which is what makes it the one case that discriminates coupled from merely-agreeing (see Leg C)
4 array-form cases (routes to record_change though spec answers no kind; pre-check outranks timeRelative; raw array + joined event forwarded; an array with no record-* element is left to the resolver)"unifying" the divergence away, and re-ordering the pre-check behind the resolver
array-form-refusal-end-to-end.test.ts (new, in @objectstack/trigger-record-change)the refusal itself — engine → real RecordChangeTrigger → the warn, asserted by flow name, "ARRAY", "NOT bound / never fire", the record-after-write steer and #3457, plus zero hooks registered. Its third case is an anti-vacuity control: a legitimate single record-after-update token draws no warn and arms afterUpdate

The e2e file lives in trigger-record-change because that is the only side of the edge where both halves exist: @objectstack/service-automation is a devDependency there, and the trigger is deliberately not a dependency of the engine. Same split — and same dist/-resolution note — as the existing reentrant-start-condition.test.ts.

Ablation

Directions were written down before any leg ran. Resolution facts that shape the legs, measured not assumed: service-automation's own tests read engine.ts from source and @objectstack/spec through exports to dist/; trigger-record-change's tests read @objectstack/service-automation through exports to dist/ (both from check:test-source-alias's KNOWN_UNALIASED_TEST_IMPORTS). @objectstack/spec is external in the engine's bundle, so rebuilding spec alone reaches the engine's dist. Every leg rebuilt what it mutated; the driver carried trap … EXIT INT TERM with absolute paths.

legmutationpredictedmeasured
Arevert the unification (engine.ts → merge base)nothing reddens — a behaviour-preserving refactor cannot redden behaviour pins; stated in advance as not discriminating✅ as predicted. 19/19 + 3/3 green
Bkeep the unification, delete the array-form pre-check (the "unify it away" mistake)red: 3 array cases here + the existing trigger-dispatch-observability array case + 2 of 3 e2e cases; green: all 7 precedence cases, the reachability case, and the e2e anti-vacuity case✅ exactly. 4 failed | 15 passed in service-automation, 2 failed | 1 passed e2e. Reddened: routes array form to record_change…, keeps the array pre-check AHEAD of the resolver…, hands the record-change trigger the raw array…, hands an array triggerType to the record_change trigger…, refuses the array form by name…, gets there because the ENGINE routed it…
C1spec's resolver loses its api branch, spec rebuilt; unified enginered on the api cases only3 failed | 11 passed: both api precedence cases (failing at line 134, the engine half) and reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS (line 189)
C2same mutated spec, old private chain❌ I predicted fully green; it was 2 failed | 17 passedthe two api cases redden here too — but at line 139, the half that queries resolveFlowTriggerKinddirectly. Line 134, the engine half, stayed green, and the reachability case stayed green

C1 vs C2 is the leg that measures what this card buys, and the line numbers are the measurement. With the unification, a spec-side change moves the engine's answer (line 134 reddens, reachability reddens). Without it, the engine is untouched by the same spec change (line 134 green, reachability green) and only the direct spec query reddens. That is the drift the card describes, demonstrated in both directions.

My C2 prediction was wrong and the reason is worth recording rather than smoothing over: a pin's "and to what spec answers" half asks spec directly, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The coupling measurements are the engine-half literal and the reachability case. Neither leg reddened everything.

Mutation-reached-disk proof. Literal-text counts before/after on the mutated file each time (resolveFlowTriggerKind 2 → 0; Array.isArray(config.triggerType) 1 → 0; f.type === 'api' 1 → 0), each aborting the leg on a miss. One marker was initially vacuous and is reported as such: scripts/ablation-dist-preflight.mjs with the single-quoted source spelling f.type === 'api' matched only sourcemaps, because the bundler re-quotes to double quotes — the tool caught it itself (✗ … found ONLY in 6 sourcemap files … Treat this run as void). Leg C was re-run with the bundling-stable marker triggerType === "api") return "api": ✓ marker present in 6 built files before, ✓ marker absent from all 215 built files after the mutated rebuild, ✓ present again after the restore rebuild, and the same 3-case red set reproduced.

Restore proof.git hash-object equal to the HEAD blob for both mutated files (engine.ts00401ee1…, flow-trigger-kind.ts2e7c8137…), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on the merged head (origin/main merged in first — the earlier derivation warned STALE TREE and named the two files it read stale copies of, so the list below is the one derived from a tree someone is on). Every exit code captured by redirect-then-read, never across a pipe.

41 commands, all captured by redirect-then-read. 38 exited 0; 3 exited 3 and are NOT MEASURED — neither green nor red — quoted below from each gate's own verdict text.

Exit 0 (38):node scripts/check-adr-0087-registration.mjs · node scripts/check-changeset-no-major.mjs · node scripts/check-ci-filter-parity.mjs · node scripts/check-closing-keyword-parity.mjs · node scripts/check-comment-mask-adoption.mjs · node scripts/check-comment-mask-corpus.mjs · node scripts/check-cross-package-test-inputs.mjs · node scripts/check-empty-changeset.mjs · node scripts/check-keyed-text-bounds.mjs · node scripts/check-plugin-teardown-shape.mjs · node scripts/check-shard-attestation.mjs · node scripts/check-system-context-census.mjs · node scripts/check-tenant-audit-census.mjs · node scripts/check-undeclared-dep-imports.mjs · node scripts/docs-audit/check-affected-docs.mjs · node scripts/docs-audit/check-drift-comment.mjs · node scripts/pm/check-half-states.mjs · node scripts/pm/release-rehearsal-clone.mjs --self-test · pnpm check:changeset-gate-self-tests · pnpm check:cross-package-test-inputs · pnpm check:dispatcher-error-vocabulary · pnpm check:doc-authoring · pnpm check:engine-double-contract · pnpm check:logger-receiver-detach · pnpm check:nul-bytes · pnpm check:objectql-double-limit · pnpm check:objectui-changeset · pnpm check:page-declaration-shape · pnpm check:pm-half-states · pnpm check:published-files · pnpm check:query-options-erasure · pnpm check:refd-timer-probe · pnpm check:slot-lookup · pnpm check:test-source-alias · pnpm check:type-check-coverage · pnpm check:type-source-resolution · pnpm check:watch-hint-literal · pnpm check:where-matcher

NOT MEASURED (exit 3, 3):

  • node scripts/check-test-completeness.mjs — exit 3

Nothing was measured: this gate exited before parsing a single summary line, so
this result says NOTHING about whether every test vitest counted actually ran,
nor about whether every scheduled package reported.
⛔ It is NOT a finding, and it is not evidence that anything in the tree is wrong.

It wants a saved turbo run test log; running the family locally, its own fix line says to "record this gate as NOT MEASURED".

  • pnpm check:dual-build-cjs-loads — exit 3

PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.
· @objectstack/hono#. -> ./dist/index.js (no packages/adapters/hono/dist)
… 40 more
Run pnpm build first. ⛔ This is NOT a pass: nothing was measured.

Its own self-test passed first (✓ check-dual-build-cjs-loads self-test: 93 cases pass); only the repo-wide built-output leg is unmet. Needs a whole-repo pnpm build, which is CI's run, not mine.

  • pnpm check:type-check-debt — exit 3

⛔ This is NOT a pass and NOT a finding: nothing was measured, so this run says
NOTHING about whether any DEBT or TEST_DEBT number is still correct.

Same cause — it asks for the closure build lint.yml does first (pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'). Note pnpm check:type-check-coverage exited 0 beside it.

Two notes on the derived list rather than trusting it blind: dispatch-gates.mjs keys entries by script path, so a script CI invokes twice collapses to one entry; and the derived list excludes CI's always-runs tail. Re-derived after the merge commit that changed the file set, and the union above was run on the final head 39fcadf65 — same tree as the suite run below.

docs-drift-check disposition — 11 pages opened, zero doc edits, and why

The bot is advisory, so this is the disposition rather than a skip. Its hypothesis — that nothing needs an edit because the refactor is behaviour-neutral — was tested, not inherited: ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can be describing behaviour this PR moved. The bot also discloses that it computed on merge commit f8f059e9a631576a850615bf7116350bba098063 rather than this PR head, and that a page stating the rule by its inputs shares no identifier with the emitter and cannot be listed at all — so the list was treated as a starting point, not the population.

Its 10 hand-written pages. All flagged through the record_change / time_relative string literals in resolveTriggerBinding, which this PR neither renames nor re-values.

pagewhy no edit
automation/flows.mdx (flagged twice, via time_relative too)Three passages read. :1411 "Registration is not arming … defineStack refuses a stack … naming the flow, the trigger kind it resolved" describes the authoring side, which already read resolveFlowTriggerKind before this PR. :1818 the single-event triggerType table — every token still resolves to record_change. :1742 the time-relative sweep, shown with a schedule cadence besidetimeRelative: that is precisely the precedence case, and it is preserved (the resolver ranks time_relative ahead of schedule for the same reason the private chain did). This PR makes that documented ordering pinned rather than incidental
permissions/capabilities.mdx (flagged twice, via time_relative too):28 lists record_change / schedule / time_relative / api as the flow kinds needing triggers. That set is FLOW_TRIGGER_KINDS, unchanged — and now asserted reachable through the real engine by a new case
automation/approvals.mdx, automation/workflows.mdx, concepts/architecture.mdx, getting-started/common-patterns.mdxAuthoring examples: type: 'record_change' flows with single-string triggerType tokens. Same kind, same binding, same fields
automation/hooks.mdxThe hooks-vs-flows chooser, naming record_change and schedule/timeRelative as options. Unchanged
api/plugin-endpoints.mdx, kernel/services-checklist.mdx, protocol/objectql/schema.mdxSingle passing mentions of record_change as a concept

The blind spot the bot warned about, checked. Searching the docs tree for the rule stated by its inputs (timeRelative, array-form / multi-event prose) surfaced one page not on the list: content/docs/references/automation/time-relative-trigger.mdx. It describes config.timeRelative with an optional schedule cadence — accurate under this PR, and in any case it is marked ⚠️ AUTO-GENERATED — DO NOT EDIT, generated from packages/spec/src/automation/time-relative-trigger.zod.ts, which this PR must not touch. No page anywhere in content/docs/ describes array-form triggerType handling, and none mentions getTriggerBindingAudit or the boot banner's contents, so the two places the coordinator named as able to falsify the hypothesis have no documentation to go stale.

The 3 release-owned pages: read, not touched, and none is wrong.releases/v17.mdx:1536 states "array-form triggerType fails loudly instead of silently never firing" — that is exactly the behaviour this PR preserves as an explicit pre-check, so it remains correct. releases/v16.mdx:393 (the time_relative trigger) and :409 (the kernel:bootstrapped binding audit warning per enabled-but-unbound flow) and releases/v12.mdx:159 are all still accurate. Nothing to file.

Patch round 1 — the type-check DEBT lane was red, and it was mine

Two red checks, one root cause.Type Check · debt ledger failed on 39fcadf65 with @objectstack/service-automation: DEBT records 3 raw tsc error(s), tsc --noEmit now reports 4 (+1), and TypeScript Type Check was red only because it aggregates that lane (its other three — typecheck-source-gates, typecheck-workspace, typecheck-consumers — were green on the same head). One fix clears both.

Measured, not guessed. Built the closure, then ran the package's tsc --noEmit directly:

src/flow-trigger-kind-shared-resolver.test.ts(250,21): error TS7006: Parameter 'binding' implicitly has an 'any' type.
src/nested-region-parity.test.ts(95,25): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(151,20): error TS2341: Property 'flows' is private …
src/nested-region-parity.test.ts(180,20): error TS2341: Property 'flows' is private …

The three TS2341 are the ledger's recorded pile, at exactly the lines its note itemises (95/151/180). The +1 was mine, in the new pin file — consistent with the aggregator's narrowing, since this package has no typecheck script, so its tsc --noEmit runs only in the debt lane.

The cause. The recording trigger double was an object literal cast as never. The cast erases the contextual type for start, leaving binding with nothing to infer from.

The fix is a type fix. The double is now typed as the real FlowTrigger, the capture array as FlowTriggerBinding[] — the shape engine.test.ts already uses — and the cast is gone. Both assertion lines in that case are byte-identical; git diff touches no line containing expect. What the pin checks, and the array-form divergence it guards, are unchanged.

Proof.

tsc --noEmit (packages/services/service-automation) → 3 errors, exit 2
the ledger's 3 exactly, same composition (TS2341 ×3, nested-region-parity.test.ts 95/151/180)
pnpm check:type-check-debt → EXIT=0 (captured by redirect-then-read)
✓ check:type-check-coverage --self-test — 48 semantic case(s) + 68 observation case(s)
+ 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s)
+ 18 exit-code case(s) hold.
check-type-check-coverage --re-measure: OK — 18 ledger entr(ies) re-measured in 192.9s,
218 raw tsc error(s) total, none above its recorded number.

⛔ The ledger entry was not raised, nothing was @ts-expect-error-ed, no tsconfig loosened, no test skipped. @objectstack/trigger-record-change's own typecheck (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json) also exits 0.

Why the local run could not have caught it. This is the same gate the original PR body records as exit 3 / NOT MEASURED (PREREQUISITE NOT MET … nothing was measured) — an honest reading for a container with no whole-repo dist/. CI, with a full build, was the first place it actually ran. Recorded as a lesson: a NOT MEASURED gate is not a quiet pass, and one your diff can move is the one most likely to surprise you in CI.

Re-verification on the new head b7cffa0ec (supersedes the two sections above)

origin/main was merged again first — dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself among the files it had read stale copies of, which is the gate at issue. (Those upstream ledger edits are @objectstack/metadata graduations; the service-automation entry is untouched.)

  • Gate union re-derived and re-run in full: 41/41, 40 exit 0, 1 NOT MEASURED. With a whole-repo build present, check:dual-build-cjs-loads and check:type-check-debt now both exit 0 — they were the NOT MEASURED entries in the original run. The single remaining NOT MEASURED is node scripts/check-test-completeness.mjs (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.
  • Suites green, unchanged:service-automationTest Files 103 passed (103) / Tests 1223 passed (1223); trigger-record-changeTest Files 8 passed (8) / Tests 81 passed (81).

Clause-②: no — unchanged and unaffected: a test-file type annotation adds no exported symbol, no payload key and no accept-set change.

Deliberately not done

  • No packages/spec edit. It is single-owner and this PR is a consumer of resolveFlowTriggerKind. The change needed none — the temporary spec mutation in Leg C was an ablation, restored with hash proof and never committed.
  • The array-form divergence was not unified, per the card, the triage note and the resolver's own header. It stays a pre-check with its own pins on both sides.
  • No content/docs/releases/** edit. The release-notes input is the changeset.
  • No behaviour change was smuggled in. Leg A is the evidence, not the claim.

Verification, on the final head 39fcadf65

pnpm --filter '@objectstack/service-automation^...' build # closure first: spec resolves to dist/ here
pnpm --filter '@objectstack/service-automation' --filter '@objectstack/trigger-record-change' test
service-automation Test Files 103 passed (103) Tests 1223 passed (1223)
trigger-record-change Test Files 8 passed (8) Tests 81 passed (81)
os-verify-lock: VERDICT command-exit 0

Clause-②: no

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…d resolver (#14328)
`AutomationEngine.resolveTriggerBinding` kept a private copy of the chain that
decides which trigger kind a flow asks for, in parallel with
`@objectstack/spec`'s `resolveFlowTriggerKind` — the authoring-time mirror that
`defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
`validate-flow-trigger-readiness` already read. Nothing pinned the copies
together. The engine now takes the kind from the shared resolver and keeps only
the per-kind binding construction.
The array-form `triggerType` divergence is PRESERVED as an explicit pre-check
before the resolver call: spec answers no kind for it on purpose, and the engine
routes it to the record-change trigger only so that trigger can refuse it loudly
at bind time (#3457/#3481). Its ordering is load-bearing — array form outranks
`timeRelative`, which the resolver would otherwise answer for a start node
carrying both.
Two new guards close the drift: the per-kind switch is exhaustive over
`FlowTriggerKind` with a `never` default, so a kind added to spec fails this
package's type-check; and new pins assert every kind in `FLOW_TRIGGER_KINDS` is
reachable through the real engine, plus the preserved divergence end-to-end
against the real record-change trigger's refusal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 4 documentable anchor(s).

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

  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/permissions/capabilities.mdx(via record_change (literal, a string literal in resolveTriggerBinding), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in resolveTriggerBinding))

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

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v16.mdx(via AutomationEngine (symbol, a top-level class), time_relative (literal, a string literal in resolveTriggerBinding))
  • content/docs/releases/v17.mdx(via AutomationEngine (symbol, a top-level class), record_change (literal, a string literal in resolveTriggerBinding))

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

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 5 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 55eac1e1304fc187d837e69a0ef459422904a0c3packageMentionDocs.

Which tree this was computed on

This run read content/docs from b752853db28bfead3687cd69fe5117cc5f415669 — the merge of head b7cffa0ec82e354157d793827dd991f3eb3cbe52 into base 55eac1e1304fc187d837e69a0ef459422904a0c3, 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 b752853db28bfead3687cd69fe5117cc5f415669 && git checkout b752853db28bfead3687cd69fe5117cc5f415669
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 55eac1e1304fc187d837e69a0ef459422904a0c3 b7cffa0ec82e354157d793827dd991f3eb3cbe52 && git checkout -B drift-repro 55eac1e1304fc187d837e69a0ef459422904a0c3 && git merge --no-ff b7cffa0ec82e354157d793827dd991f3eb3cbe52
node scripts/docs-audit/affected-docs.mjs --json 55eac1e1304fc187d837e69a0ef459422904a0c3

⚠️ 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 55eac1e1304fc187d837e69a0ef459422904a0c3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

{
"issue": 14328,
"status": "done",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"premise_still_valid": true,
"summary": "AutomationEngine.resolveTriggerBinding now takes its trigger KIND from spec's resolveFlowTriggerKind and keeps only the per-kind binding construction; the array-form triggerType divergence is PRESERVED as an explicit pre-check BEFORE the resolver call, and its ordering is pinned (array form outranks timeRelative, which the resolver would otherwise answer for a start node carrying both). Two drift guards added: the per-kind switch is exhaustive over FlowTriggerKind with a never default (a kind added to spec fails this package's type-check), and a case asserts every kind in FLOW_TRIGGER_KINDS is reachable through the real engine. Clause-2 re-derived from my own diff as 'no' and stated on its own line in the PR body: no exported symbol, no payload key, no accept-set change -- and measured, not argued, by ablation Leg A (reverting the unification reddened NOTHING, so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included). All five falsifiers checked and none fired: no spec edit needed; resolveFlowTriggerKind confirmed importable from the built @objectstack/spec/automation subpath; the two chains agree on every string-form flow (Leg A is the measurement); the array divergence is fully compatible with taking the kind from the resolver; nobody had wired it in. Three process notes, reported rather than smoothed: (1) I read AGENTS.md AFTER my first file edit, not before -- nothing in it was violated, but the ordering was wrong; (2) my ablation Leg C2 prediction was WRONG and I report it as such, with the diagnosis (see 'tests'); (3) the dispatch said to post this report as a PR comment while the standing os-dev contract says the card comment is the authoritative copy, so I posted it to BOTH (issue 14328 and PR 14994) rather than silently picking a side. Merged origin/main into the branch before deriving gates, because dispatch-gates.mjs reported STALE TREE and named the two files it had read stale copies of.",
"tests": "All on final head 39fcadf65, every exit code captured by redirect-then-read (never across a pipe).\nSUITES: pnpm --filter '@objectstack/service-automation^...' build (closure first -- service-automation resolves @objectstack/spec through exports to dist/), then pnpm --filter service-automation --filter trigger-record-change test => 'Test Files 103 passed (103) / Tests 1223 passed (1223)' and 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNEW PINS: 14 cases in packages/services/service-automation/src/flow-trigger-kind-shared-resolver.test.ts (7 precedence cases each asserting a LITERAL kind through the real engine, plus the audit surface and a FLOW_TRIGGER_KINDS reachability case), and 3 in packages/triggers/trigger-record-change/src/array-form-refusal-end-to-end.test.ts (engine -> real RecordChangeTrigger -> the refusal asserted by flow name, 'ARRAY', 'NOT bound / never fire', the record-after-write steer and the 3457 citation, zero hooks registered, plus an anti-vacuity control).\nGATES: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands => 41 commands, re-derived after the merge commit. 38 exited 0. 3 exited 3 and are NOT MEASURED, quoted from their own verdict text in the PR body: check-test-completeness ('Nothing was measured: this gate exited before parsing a single summary line ... It is NOT a finding'), check:dual-build-cjs-loads ('PREREQUISITE NOT MET -- this gate reads built output, and some package has no dist/ ... This is NOT a pass: nothing was measured'; its own self-test passed first, 93 cases), check:type-check-debt ('This is NOT a pass and NOT a finding: nothing was measured'; check:type-check-coverage exited 0 beside it). No gate exited 1.\nABLATION -- 4 legs, directions written down BEFORE running, driver carried trap ... EXIT INT TERM with absolute paths, and each leg rebuilt what it mutated:\n LEG A (revert the unification, engine.ts -> merge base): predicted NOTHING reddens and said in advance that this leg does NOT discriminate. Measured: 19/19 + 3/3 green, as predicted. This is the Clause-2 measurement.\n LEG B (delete the array-form pre-check): predicted a SPECIFIC red set. Measured exactly it -- '4 failed | 15 passed' in service-automation (3 of my array cases + the existing trigger-dispatch-observability array case) and '2 failed | 1 passed' end-to-end; all 7 precedence cases, the reachability case and the anti-vacuity control stayed GREEN. Discriminating.\n LEG C1 (spec's resolver loses its api branch, spec rebuilt; unified engine): predicted red on the api cases only. Measured '3 failed | 11 passed' -- both api precedence cases failing at test line 134 (the ENGINE half) plus 'reaches EVERY kind spec publishes in FLOW_TRIGGER_KINDS' at line 189.\n LEG C2 (same mutated spec, OLD private chain): I predicted FULLY GREEN and I WAS WRONG -- measured '2 failed | 17 passed'. Diagnosis: the two api cases redden at line 139, the half that queries resolveFlowTriggerKind DIRECTLY, so it reddens on a spec mutation regardless of the engine and cannot be a coupling measurement. The engine half (line 134) and the reachability case both stayed GREEN. C1-vs-C2 is therefore still the leg that measures what this card buys, and the LINE NUMBERS are the measurement: with the unification a spec-side change moves the engine's answer; without it the engine is untouched. No leg reddened everything.\nMUTATION-REACHED-DISK: literal-text counts before/after on the mutated file each leg, each aborting on a miss -- resolveFlowTriggerKind 2 to 0, Array.isArray(config.triggerType) 1 to 0, f.type === 'api' 1 to 0. ONE MARKER WAS VACUOUS AND I REPORT IT: ablation-dist-preflight.mjs with the single-quoted SOURCE spelling matched only sourcemaps, because the bundler re-quotes to double quotes -- the tool caught it itself ('marker found ONLY in 6 sourcemap files ... Treat this run as void'). Leg C was RE-RUN with the bundling-stable marker 'triggerType === \"api\") return \"api\"': present in 6 built files before, 'absent from all 215 built files' after the mutated rebuild, present again after the restore rebuild, and the same 3-case red set reproduced.\nRESTORE PROOF: git hash-object equal to the HEAD blob for both mutated files (engine.ts 00401ee1..., flow-trigger-kind.ts 2e7c8137...), git diff HEAD empty, whole-tree git status --porcelain empty, both packages rebuilt from restored source, and 19/19 + 3/3 green again on the restored tree. The temporary packages/spec mutation was an ablation only -- never committed; the shipped diff touches no spec file.",
"mcp_calls": "0 -- REST via container curl for every GitHub read and write (probe returned 200); no MCP GitHub call was made this run",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

PM ACCEPT — domain:services execution seat

Session session_01AUF1NoViznQK32gqpK8wS8, reviewer of record. Verified against the tree at head 39fcadf65, ⛔ not against the report — the report is the thing under review, not evidence for it.

The load-bearing claim, re-read at the tree

The one way this card could have been got wrong was unifying the array-form divergence away. It was not:

  • the array-form pre-check block ends at engine.ts relative line 54; const kind = resolveFlowTriggerKind(flow); is at relative line 56. The pre-check genuinely precedes the resolver.
  • resolveTriggerBinding is still private; switch (kind) carries default: { const unhandledKind: never = kind; }, so a kind added to spec fails this package's type-check rather than falling through silently.
  • git diff --name-only origin/main...HEAD touches nopackages/spec/** and nocontent/docs/releases/**. Four files, +584/−56.

⭐ The in-code comment states the reason better than my dispatch order did, and the improvement is substantive rather than editorial. I said "preserve the divergence so the refusal stays loud." The dev's comment adds why the resolver must not answer for it at all: array form resolves to no kind on purpose, because reading it as "asks for a record-change trigger" would make defineStack demand a capability for a flow that can never use it, and would widen the lint rule's auto-triggered set. It also names a consequence I had not: the pre-check ordering preserves this method's own precedence — array form outranks timeRelative/schedule, which the resolver, blind to array form, would otherwise answer for a start node carrying both.

Clause-② no — accepted, and accepted because it is MEASURED

The declaration is re-derived from the dev's own diff rather than inherited from my claim comment, which is what I asked for. More importantly it is measured, not argued: Ablation Leg A reverted the unification and reddened nothing — so getTriggerBindingAudit and the boot banner report the same kind for every flow, array form included. That is the correct instrument for Clause-②: if reverting the change is unobservable, the change widens no accept set. No exported symbol, no payload key, resolveTriggerBinding still private. No contract-review tier is owed.

⭐ The wrong prediction, and why reporting it was the right call

Leg C2 was predicted fully green and measured 2 failed | 17 passed. The dev reported that as a wrong prediction rather than quietly rewriting the prediction to match, and diagnosed it correctly: the two api cases redden at test line 139, the half that queries resolveFlowTriggerKinddirectly, so that half reddens on any spec mutation regardless of the engine and cannot measure coupling. The engine half (line 134) and the reachability case stayed green.

The diagnosis is right, and the conclusion drawn from it is the useful one: C1-vs-C2 is still the leg that measures what this card buys, and the line numbers are the measurement — with the unification a spec-side change moves the engine's answer; without it the engine is untouched. A dev that had silently adjusted its prediction would have produced a tidier report and a worse one.

Two more disclosures I want on the record as good practice, not as faults: the vacuous marker (ablation-dist-preflight matched only sourcemaps because the bundler re-quotes single to double quotes) was caught by the tool, declared void, and the leg re-run with a bundling-stable marker — present in 6 built files before, absent from all 215 after, present again after restore. And the AGENTS.md read-ordering slip was self-reported though nothing in it was violated.

Landing state

Not armed yet. CI on 39fcadf65 reads 30 runs: 10 success, 3 skipped, 17 still in progress, zero failures. In-progress is not green, and "no failures" on a partially-run head is the same reading a head with no runs at all would give. Auto-merge goes on when the job-level read comes back complete with nothing red — not before.

pm:dispatched comes off card #14328 on MERGED.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

docs-drift-check disposition — 11 pages opened, zero doc edits. Recorded in full in the PR body (section "docs-drift-check disposition"); this comment is the thread-visible pointer, not a second os-dev-report.

Short form: the bot's hypothesis was tested rather than inherited — ablation Leg A reverted the unification with the pins in place and reddened nothing, so no page can describe behaviour this PR moved. All 10 hand-written pages were flagged through the record_change / time_relative string literals, which this PR neither renames nor re-values.

The two places named as able to falsify it have no documentation to go stale: no page in content/docs/ describes array-form triggerType handling, and none states what getTriggerBindingAudit or the boot banner report. Following the bot's own warning about pages that state a rule by its inputs, one unlisted page was found and read — content/docs/references/automation/time-relative-trigger.mdx — which is accurate here and is in any case marked AUTO-GENERATED — DO NOT EDIT from a packages/spec source this PR must not touch.

The 3 release-owned pages were read, not touched, and none is wrong — notably releases/v17.mdx:1536 ("array-form triggerType fails loudly instead of silently never firing") states exactly the behaviour this PR preserves as an explicit pre-check before the shared resolver. Nothing to file.

Generated by Claude Code


Generated by Claude Code

…tead of casting it away (#14328)
The type-check DEBT lane went red on the previous head: `@objectstack/service-automation`
measured 4 raw `tsc --noEmit` errors against a ledger entry recording 3.
The +1 was mine and it was in the new pin file:
src/flow-trigger-kind-shared-resolver.test.ts(250,21):
error TS7006: Parameter 'binding' implicitly has an 'any' type.
The recording trigger double was written as an object literal cast `as never`.
The cast erases the contextual type for `start`, so `binding` had no type to be
inferred from. Fixed by typing the double as the real `FlowTrigger` and the
capture array as `FlowTriggerBinding[]` — the same shape `engine.test.ts` already
uses — which restores the contextual type and removes the cast entirely.
This is a TYPE fix only. Both assertion lines in that case are byte-identical;
what the pin checks is unchanged, and the array-form divergence it guards is
untouched. The ledger entry is NOT raised: re-measured back to exactly the 3 it
records, same composition (TS2341 x3 in nested-region-parity.test.ts at
95/151/180, the pre-existing private-`flows` reads).
This package has no `typecheck` script, so its `tsc --noEmit` runs only in the
debt lane — which is why the error was invisible to the local gate run, where
that gate honestly reported PREREQUISITE NOT MET / nothing measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing conditions MET — held only by API quota, not by anything in this PR

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Recording state durably so it survives this session and so the next reader does not re-derive it.

CI on 4dd73b545 is fully green, read at job level rather than from an aggregate: 33 check runs — 30 success, 3 skipped, 0 pending, nothing not-green. Both checks that were red on the previous head now pass:

checkon 39fcadf65on 4dd73b545
Type Check · debt ledgerfailuresuccess
TypeScript Type Checkfailuresuccess
Lint & Repo Gatessuccess

mergeable_state: clean.

The patch round, verified at the tree

The two reds were one root cause: TypeScript Type Check is an aggregator, and three of its four lanes (typecheck-source-gates, typecheck-workspace, typecheck-consumers) passed on the red head — it was red purely because it re-reports typecheck-debt.

The fix (4dd73b545, one file, +11/−4) replaces an … as never cast — which erased the contextual type and left a callback parameter implicitly any (TS7006) — with a properly typed const trigger: FlowTrigger. Verified by reading the diff, not the report:

  • started is typed FlowTriggerBinding[] instead of a loose inline shape — stronger, not weaker;
  • the binding as (typeof started)[number] cast is removed, not added;
  • no assertion changedexpect(started).toHaveLength(1) and everything below it is untouched.

⛔ Nothing was ledgered. The shrink-only ratchet was not raised, and no @ts-expect-error was used — the gate's own text forecloses both, and the author's remedy was taken instead.

⛔ What is blocking, and it is not this PR

The two remaining actions — mark ready for review, then enable auto-merge (squash) — are both GraphQL-only operations on GitHub. This session's GraphQL endpoint serves only a pinned set of PR-review operations ("This GraphQL query is not enabled for this session"), and REST's PATCH /pulls/:n silently ignores draft (returns 200 with draft unchanged — verified, not assumed). So both steps require the MCP GitHub tools, and that pool is currently exhausted:

API rate limit already exceeded for user ID 319429713

Per this seat's standing rule the response is to back off rather than poll or loop-retry. The repo-scoped REST pool is separate and healthy (15000/15000), which is how this comment is being written — but it cannot serve the two remaining operations.

Order when quota returns, and the order matters: mark ready first, then arm auto-merge — auto-merge does not survive a draft conversion, so arming first would be undone by the undraft.

No carriers to clear: Clause-②: no, measured by ablation Leg A (reverting the unification reddened nothing), so no contract-review tier is owed. PM ACCEPT is comment 5529090987.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 3, 2026 17:51
@os-sales
os-sales added this pull request to the merge queueSep 3, 2026
@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

os-dev-report

Patch round 1 -- supersedes the round-0 report above for gate/test readings; the implementation, pins and ablation are unchanged.

{
"issue": 14328,
"status": "done",
"round": "patch round 1",
"branch": "claude/issue-14328-trigger-kind-resolver",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14994",
"head": "b7cffa0ec",
"premise_still_valid": true,
"summary": "Patch round 1: the Type Check debt lane was red on 39fcadf65 and it was mine. Measured rather than guessed -- built the closure, ran the package's own tsc --noEmit, and got 4 errors: 3 TS2341 in src/nested-region-parity.test.ts at 95/151/180 (exactly the pile the ledger entry records and itemises) plus ONE new TS7006 at src/flow-trigger-kind-shared-resolver.test.ts(250,21), 'Parameter binding implicitly has an any type'. Cause: the recording trigger double was an object literal cast 'as never', and the cast erases the contextual type for start, so binding had nothing to infer from. Fixed as a TYPE fix only -- the double is now typed as the real FlowTrigger and the capture array as FlowTriggerBinding[], the same shape engine.test.ts already uses, and the cast is gone. BOTH assertion lines are byte-identical: git diff touches no line containing 'expect', so what the pin checks and the array-form divergence it guards are unchanged. The ledger entry was NOT raised, nothing was ts-expect-error'd, no tsconfig loosened, no test skipped. The second red check (TypeScript Type Check) was the aggregator re-reporting the same lane -- its other three lanes were green on that head -- so one fix cleared both, and the aggregator's narrowing was confirmed by measurement rather than taken on trust: this package has no typecheck script, so its tsc --noEmit runs only in the debt lane, which is exactly why the original local gate run honestly reported that gate as exit 3 / NOT MEASURED and could not have caught it. Merged origin/main again before re-verifying because dispatch-gates.mjs reported STALE TREE and named scripts/check-type-check-coverage.mjs itself -- the gate at issue -- among the stale files; those upstream ledger edits are metadata graduations and the service-automation entry is untouched. Two observations for the seat, neither acted on: (1) the PR read back as draft:false after my body PATCH -- I did not set that and did not touch it, since REST PATCH /pulls does not accept 'draft' and I sent only 'body'; it is consistent with the coordinator's stated intent to do the undrafting and arming. (2) During the union re-run my first 'tail --pid' attached to a stale pid and reported the runner gone at 25/41 while the real runner was still holding the lock; I re-attached to the real pid and completed 41/41 rather than reporting the partial set.",
"tests": "All on final head b7cffa0ec, exit codes captured by redirect-then-read.\nTSC (the actual measurement): cd packages/services/service-automation && tsc --noEmit => exit 2, 3 errors, all TS2341 'Property flows is private' in src/nested-region-parity.test.ts at 95/151/180 -- the ledger's recorded number AND composition. Before the fix the same command reported 4, the extra being src/flow-trigger-kind-shared-resolver.test.ts(250,21) TS7006.\nDEBT GATE (first time it has actually run in this container -- it needed the whole-repo build that made it exit 3 before): pnpm check:type-check-debt => EXIT=0, both halves. Verdict text: '✓ check:type-check-coverage --self-test -- 48 semantic case(s) + 68 observation case(s) + 43 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s) + 18 exit-code case(s) hold.' and 'check-type-check-coverage --re-measure: OK -- 18 ledger entr(ies) re-measured in 192.9s, 218 raw tsc error(s) total, none above its recorded number.'\nSIBLING PACKAGE: pnpm --filter '@objectstack/trigger-record-change' typecheck => exit 0 (both halves, tsc --noEmit && tsc --noEmit -p tsconfig.test.json). NOTE: an earlier run of it showed TS2307 'Cannot find module @objectstack/service-automation' in two PRE-EXISTING files; that was an unmet prerequisite in a fresh worktree (service-automation had no built .d.ts because I had built only the '^...' closure, which excludes the package itself), not a finding -- building service-automation cleared it. Reported because it reads exactly like a real failure.\nGATE UNION re-derived and re-run in full on the final head: 41/41 recorded, 40 exit 0, 1 exit 3. With a whole-repo build present, check:dual-build-cjs-loads AND check:type-check-debt now both exit 0 -- they were two of the three NOT MEASURED entries in the pre-patch run. The single remaining NOT MEASURED is 'node scripts/check-test-completeness.mjs' (exit 3), which wants a saved turbo run test log and whose own fix line says to record it as NOT MEASURED when running the family locally.\nWHOLE-REPO BUILD: pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 => 71 successful, 71 total, exit 0 (run twice: once pre-merge, once post-merge).\nSUITES: service-automation 'Test Files 103 passed (103) / Tests 1223 passed (1223)'; trigger-record-change 'Test Files 8 passed (8) / Tests 81 passed (81)'; os-verify-lock VERDICT command-exit 0.\nNO NEW ABLATION for this round: the change is a test-file type annotation with byte-identical assertions, so there is no behaviour to ablate. The original 4-leg ablation stands unchanged on the engine diff, which this round did not touch.",
"mcp_calls": "0 for this round as well -- REST via container curl for every GitHub read and write; still zero MCP GitHub calls across the whole card",
"open_questions": [],
"out_of_scope_findings": []
}

Generated by Claude Code


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

Blocked on enqueue — auto-merge accepts and does not persist. Stating it once.

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. Head b7cffa0ec.

This PR is done on every axis I control. CI job-level on b7cffa0ec: 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green, Type Check · debt ledger and TypeScript Type Check both success. Non-draft. Clause-②: no, so no carriers are owed. PM ACCEPT is comment 5529090987; the patch round is verified at the tree (assertions byte-identical, ledger not raised).

What is actually blocking

Auto-merge does not persist. Twice now:

timeactionresult
17:51:34Zenable auto-mergeAPI reported enabled
17:54:04Zread backauto_merge: none, not in the queue
~17:55Zre-armed (once — the limit)API reported enabled
17:57:26Zread backauto_merge: none, not in the queue

Throughout, mergeable_state reads unknown and mergeable is null — GitHub is not returning a computed mergeability for this PR, and auto-merge does not survive that state.

⛔ It is NOT a merge conflict — measured, not assumed

merge-base(origin/main, PR head) → files changed by BOTH sides since it:
(empty)

The branch already merged origin/main at 77a532d67 (commit b7cffa0ec), and no file this PR touches is touched by anything that landed on main since. So there is nothing to resolve, and pushing a further merge commit would be a no-op that costs a full CI cycle.

The likeliest reading, offered as a hypothesis rather than a conclusion: main is advancing every few minutes while the merge queue drains (it moved fddfc8db0 → a4e4d2d78 inside this window, with 5 entries queued), and each advance re-invalidates the mergeability computation before auto-merge can act on it.

What I am doing about it

⛔ Not re-arming again — the standing rule is one re-arm, and a third would be the loop-retry the rule exists to prevent. ⛔ Not pushing an empty commit and ⛔ not closing/reopening to kick it; both are forbidden and neither addresses an uncomputed mergeability.

This PR stays watched with a check-in scheduled until it merges or the blocker changes. If mergeable_state resolves to clean and it is still unqueued, I arm again then — that is a different state, not a retry of this one.

On MERGED, pm:dispatched comes off card #14328.


Generated by Claude Code

@claude

claudeBot commented Sep 3, 2026

Copy link
Copy Markdown
ContributorAuthor

⛔ Correction to my comment above (5529902021) — the diagnosis was wrong

domain:services execution seat. This PR did enqueue. A pull_request.enqueued event fired at 17:52:29Z, so the arm at 17:51:34Z worked, and my headline "auto-merge accepts and does not persist" is false.

Two reading errors produced it, both mine:

  1. I read the queue 28 seconds too early. My git ls-remote snapshot was taken at 17:52:01Z; the enqueue happened at 17:52:29Z. I recorded "not in the queue" from a read that predates the event and then treated it as evidence about the arm.
  2. I misread auto_merge: none as the arm being dropped. GitHub clearsauto_merge when a PR enters the merge queue — the setting has done its job. So auto_merge: noneafter an enqueue is the expected state, not a failure. My table read that as the arm evaporating twice.

⚠️The second arm (~17:55Z) was therefore unnecessary, and I should not have made it: the PR was already queued when I sent it. It was harmless but it was based on the same misreading.

What the evidence actually supports

Enqueued 17:52:29Z; absent from the queue refs at 17:54:04Z, 17:57:26Z and 17:59:23Z; merged_at still null. So the real event is a dequeue without merging, some time between 17:52:29Z and 17:54:04Z — not a failure to arm. The removal notice GitHub sends for that is best-effort and did not arrive, which is why I only have the endpoints and not the cause.

⛔ I am not now asserting a cause for the dequeue. The candidates I can see — the queue's own CI run on the merged-with-main candidate, or an eviction when main advanced (fddfc8db0 → a4e4d2d78 inside this window) — are hypotheses, and I have already published one wrong diagnosis on this PR today. The next reading, not another guess, decides it.

What stands from that comment

Unchanged and still verified: CI on b7cffa0ec is 33 runs — 30 success, 3 skipped, 0 pending, nothing not-green; the PR is non-draft; Clause-②: no so no carriers are owed; and there is no merge conflict — the set of files changed by both origin/main and this PR since their merge-base is empty, measured rather than assumed.

Also unchanged: ⛔ no empty commit, ⛔ no close-and-reopen, ⛔ no merge commit for a conflict that does not exist.

Next action is to re-read the queue and merged_at and, if it is genuinely out of the queue with a computed mergeable state, arm once against that state — which is a response to a dequeue, not a third retry of an arm that in fact succeeded.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-automation: resolveTriggerBinding keeps a private copy of the trigger-kind chain that spec's resolveFlowTriggerKind now mirrors

2 participants

@os-sales@claude