Skip to content

fix(spec,objectql,sharing,storage): state per-row vs record dispatch on the hook contract (#6966) - #7101

Merged
os-zhuang merged 8 commits into
mainfrom
claude/issue-6966-per-row-bulk-marker
Aug 9, 2026
Merged

fix(spec,objectql,sharing,storage): state per-row vs record dispatch on the hook contract (#6966)#7101
os-zhuang merged 8 commits into
mainfrom
claude/issue-6966-per-row-bulk-marker

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Closes #6966.

A predicate (multi: true) write dispatches its lifecycle hooks once per matched rowafter* since #5038, before* since #5574 (ADR-0058 Addendum II) — on a context deliberately indistinguishable from a single-id write's, so a handler written for one record works unchanged on a batch.

That indistinguishability is the feature. It also erased the only signal several handlers had: before #5574 a bulk before* fired once with input.id present-but-undefined, so "input.id is empty" meant "this call stands for N rows". Every guard written on it silently inverted rather than failing — a per-row context has an id, so the guard now answers "single write" for every row of a batch.

The marker, and how its shape was measured

ctx.dispatch // { mode: 'record' | 'per-row', index: number, scope: object } | undefined

Bound by the engine at every write dispatch site (insert, update, delete, both phases), at the point the dispatch ladder is decided. It has to come from the engine: the verdict is isByIdWrite / isPredicatePath, which also consults driver capability (updateMany/deleteMany presence), and asScalarId is deliberately unexported to stop the plugin side re-deriving it from options.multi (#4434 / #4550).

Each member earned its place against a consumer, not against taste:

Member Why it is there What excluded the alternative
mode The question itself A presence-encoded marker (rowIndex?: number, absent ⇒ record) is indistinguishable from a hand-built context that carries no marker at all — and HookContextSchema is deliberately non-strict, so absent is a shape the parse accepts. Measured on the drivers in file-reference-lifecycle.test.ts, which build contexts by hand.
index index === 0 is how a handler does batch-scoped work once With mode alone the two live consumers cannot express "once per write". Measured: without it, plugin-sharing's bounded afterUpdate branch is quadraticaffectedFrom(ctx) returns the whole union and is then recomputed once per row (1000 rows ⇒ 10⁶ recomputeRow calls), and the unbounded branch issues 1001 object-wide revokes where one is correct. Both are pinned.
scope Carries state from before* to after* The two consumers already stash across the pair; that only ever worked because a single-id write reuses one HookContext. Probed on the real engine before designing: a value set on a per-row beforeUpdate/beforeDelete context reads back undefined on every after* context.
size Dropped No consumer reads it. Shipping it would be a declared-never-read member, which is the ADR-0049 shape this card is about.

Optional, and an absent marker reads as "not a per-row dispatch" — the back-compatible direction. Reads carry no marker: a read has no fan-out. The claim that every dispatched context carries one is pinned as an invariant across all six write events, not just path by path — which is what makes ctx.dispatch?.mode safe to read with no "did the engine bind it" branch, and which covers the one context a reader might worry about (update()/delete() keep a batch-scoped hookContext on the predicate path; no handler ever sees it).

This is not the discriminator #5574 retired. isPredicateBulkWrite was removed under ADR-0049 for having neither a producer nor a reachable consumer: it inferred "bulk" at the consumer from input.id + options.multi, and answered false everywhere once every dispatch carried an id. This one is produced where the verdict is made and has readers. hook-wrappers.ts's retirement note stands unchanged; the contrast is written into the new JSDoc so a future reader does not mistake one for the other.

Behaviour fixed

plugin-sharing — the card called this site "comments only". It is not.

stashAffectedRows parks the write's affected row set for the after* half. On a predicate write that stash was landing on a per-row context the after phase never sees, so readAffectedRows answered unbounded/resolve-failed and both subscribers (rule recompute, record-share cascade) took their safe branch: every bulk update or delete on a ruled object revoked all of that object's rule grants and queued a full asynchronous re-grant — once per matched row, with the repeats racing each other's re-grants.

Access is never widened (this is the ruling's "over-granting is an incident, under-granting is a wobble" direction), so it is not a fail-open — but it is a large silent behaviour change, and #4779's own tests could not see it because they drive a simulator modelling the pre-#5574 batch dispatch. A bounded write now takes the bounded path again: rows are unioned as the engine hands them over (no predicate re-query — resolveAffectedRows short-circuits on a bound input.id, so "resolve once and reuse" would have frozen the batch to row 0's id and under-revoked the rest, which is the fail-open direction), the cap still applies to the union, and the after* work runs once per write.

service-storage (site 1) — the beforeDelete id pre-resolution was dead on every path, and its stash could not have reached the after phase anyway. The pre-resolution query is now gone entirely: the engine has already matched the doomed rows, so beforeDelete collects what it is handed and afterDelete releases the batch in one sys_file lookup over an $in. beforeUpdate copy-on-claim no longer runs once per row against a batch-scoped payload, which removes a row-conditioned rewrite of a shared SET clause (out of contract under ADR-0058 Addendum II D3).

Stale comments (site 3) corrected where they sit, in both packages.

Where I read the card differently

  • The card's cost claim for site 1 is measured against a pre-[17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038 world. It says the guard "used to cost one engine.find per batch". Re-enabling the old hook today would be worse than leaving it dead: per-row afterDelete contexts are spread copies of the batch context, so all N would see the full stashed id list and each release the whole set — N lookups over N ids, plus the extra engine.find(object, { where }). The fix therefore is not "restore the guard" but "collect what the engine already matched, release once".
  • Site 3 is not text-only (above).
  • afterUpdate in file-reference-lifecycle.ts is deliberately left running per row. Its guard reads ids.length !== 1, which no longer means what its comment said — but the reconciliation it now performs is the safer outcome, and restoring the skip would leave a copied file owned by nobody while N records still reference it, which the sweep may collect. The genuine defect there (a predicate update gives N records one file id under an exclusive-ownership model) is older and wider than this card and belongs to service-storage — filed as A predicate update writing a file field gives N records one file id under an exclusive-ownership model #7102, and referenced from the comment.
  • Gate count: lint.yml has 48 pnpm check: gates as the dispatch said; 52 across all workflow files.

Verification

Reverse-verified: pre-fix sources restored with the new tests kept, predictions recorded first, then run.

Suite Predicted Actual
objectql 8 red (5 marker + 3 scope), rest green 8 failed / 49 passed ✔
service-storage 1 red, on the batching assertion only 1 failed / 39 passed ✔
plugin-sharing 4 red 4 failed / 24 passed ✔

One deviation, and it strengthens the case: for "grants every matched row", I predicted the unbounded branch would leave the grant set empty; it actually left ['opp0','opp1'] of three. The residue comes from the queued async re-grants interleaving with the repeated per-row revokes — so the pre-fix behaviour is not merely wasteful, it is racy within a single write.

Green after the fix: objectql 2786 (+58 in the touched file), plugin-sharing 414, service-storage 324, spec 9247.

Gates run locally, all clean: check:empty-changeset, check:changeset-gate-self-tests, check:kernel-hook-pairs, check:engine-double-contract, check:required-contexts, check:type-check-coverage, check:type-check-debt (needs the built closure first), check:published-files, check:query-options-erasure, check:startup-registry-verdict, check:strictness-ledger, check:docs, plus pnpm lint and pnpm typecheck (126/126 tasks).

Three generated baselines move with the schema key and are committed: packages/spec/authorable-surface/data.json, docs/audits/2026-07-unknown-key-strictness-ledger.counts.md, and content/docs/references/data/hook.mdx (the last was caught by CI's check:docs, not locally — my local sweep had missed it).

Under the full parallel pnpm test, @objectstack/types (node.test.ts host-app resolution) and four @objectstack/lint lazy-module-loading tests flake; each passes in isolation and via turbo (lint: 68 files / 1771 tests green), and neither package is touched by this diff.

Refs: #6966, #5574, #5038, #6656, #4434, #4550, #4779, #7102, ADR-0049, ADR-0058 Addendum II.

…on the hook contract (#6966)

A predicate (`multi: true`) write dispatches its lifecycle hooks once per
matched row — `after*` since #5038, `before*` since #5574 — on a context
deliberately indistinguishable from a single-id write's. That is the feature,
and it erased the only signal several handlers had: before #5574 a bulk
`before*` fired once with `input.id` present-but-`undefined`, so "no id" meant
"this call stands for N rows". Every guard written on it silently inverted
rather than failing.

Adds `HookContext.dispatch` — `{ mode: 'record' | 'per-row', index, scope }` —
bound by the engine at every write dispatch site (insert, update, delete, both
phases), at the point the dispatch ladder is decided. Optional, and an absent
marker reads as "not per-row", so existing handlers keep their behaviour.
`scope` is one object shared by every dispatch of one write across both phases:
the seam handlers used to get by stashing on the context, which only ever
worked because a single-id write reuses one context across its pair.

Deliberately not the `isPredicateBulkWrite` discriminator #5574 retired under
ADR-0049: that one inferred "bulk" at the consumer from `input.id` and
`options.multi` and ended with no producer and no reachable consumer. This one
is engine-produced and has readers.

Behaviour fixed:

- plugin-sharing — the `before*` stash of a write's affected row set was landing
  on a per-row context the `after*` phase never saw, so every bulk update or
  delete on a ruled object revoked all of that object's rule grants and queued a
  full asynchronous re-grant, once per matched row, with the repeats racing each
  other. Access was never widened; a bounded write now takes the bounded path
  again, the cap still applies to the union, and the `after*` work runs once per
  write instead of N times (the bounded branch was quadratic in batch size).
- service-storage — the `beforeDelete` id pre-resolution was dead on every path
  and `afterDelete` was doing one `sys_file` lookup per row where the batch fits
  one `$in`. The pre-resolution query is gone entirely: the engine has already
  matched the rows. `beforeUpdate` copy-on-claim no longer runs per row against
  a batch-scoped payload, removing a row-conditioned rewrite of a shared SET
  clause (out of contract under ADR-0058 Addendum II D3).

Stale comments in both packages asserting that predicate writes never populate
`input.id`, and that the engine reuses one `HookContext` across a write's
before/after pair, are corrected where they sit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 7:42pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/objectql, @objectstack/plugin-sharing, @objectstack/service-storage, @objectstack/spec.

110 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/api/plugin-endpoints.mdx (via @objectstack/service-storage)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql, @objectstack/plugin-sharing, @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-sharing, @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/plugin-sharing, @objectstack/service-storage, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via packages/plugins/plugin-sharing, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-sharing, @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/objectql, packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/plugin-sharing, @objectstack/service-storage, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/plugins/plugin-sharing, packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/plugin-sharing, @objectstack/service-storage, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…nership note (#6966)

The comment said the bulk-update file-ownership hole was "filed separately";
it is #7102. A pointer a reader can follow beats a promise they cannot check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK
claude added 4 commits August 9, 2026 15:55
…#6966)

`HookContext.dispatch`'s JSDoc claims every dispatched context carries it —
which is what makes `ctx.dispatch?.mode` safe to read with no "what if the
engine did not bind it" branch. The per-path cases prove the four write paths;
this proves the claim itself, and covers the one context a reader might worry
about: update()/delete() keep a batch-scoped `hookContext` on the predicate
path, and no handler ever sees it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK
…ch (#6966)

`content/docs/references/data/hook.mdx` is generated from the Zod schema, so
the new key has to land there too — `check:docs` is what caught it. The row
renders all three members inline (three keys, under the renderer's four-key
limit), so it carries no `…` elision and none of the `any` trap the `roles`
tombstone note warns about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK
…s "no rows" (#6966)

The accumulator only exists because a `before*` dispatch created it, so an
empty one means every id it was handed was null — "we do not know", not
"nothing changed". Reading it as an empty row set would silently skip the
cleanup entirely, which is the direction #4757 was filed for and the rule this
module states for its resolve path.

Adds direct unit cover for the accumulator: the union across rows without
re-querying the predicate, dedup across the two subscribers that both stash on
every row, the empty-union verdict, and the cap applied to the union.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK
`HookDispatch` is a new public export, so the surface snapshot moves with it —
0 breaking, 1 added. Caught by CI's `check:api-surface`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK
@os-zhuang
os-zhuang marked this pull request as ready for review August 9, 2026 19:13
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 9, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Queue steward — kicked from the merge queue at 19:13Z. NOT re-queued: this is a new signature.

Read from the complete log archive, not the tail (SKILL note 7).

Signature

  • Group: gh-readonly-queue/main/pr-7101-445a0c2866ab8deac7ec30b531753aa045e67e5d, created 19:13:40Z
  • Run: 31331084751 — workflow Lint &amp; Type Check, job TypeScript Type Checkfailure
  • Failing gate: pnpm --filter @objectstack/spec check:export-origins (tsx scripts/build-export-origins.ts --self-test &amp;&amp; tsx scripts/build-export-origins.ts --check)
  • Error, verbatim:
✅  self-test: re-exports share one origin, distinct declarations do not, aliases resolve to the declaration.
❌  1 problem(s) with export-origins/:
    • export-origins/data.json is stale — the source resolves differently
 ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL  @objectstack/spec@17.0.0-rc.5 check:export-origins
Exit status 1
##[error]Process completed with exit code 1.

The self-test passed and the gate's own --check half is what failed. Sibling gates in the same group: ADR Merge Approval / Spec Liveness Check / Console Pin Freshness success; CI was still running when the group was torn down.

Ledger verdict

No row in any of the four signature tables on #5810 matches ⇒ new signature ⇒ ⛔ this PR was not re-queued as-is. The queue currently holds zero entries; this PR is out of it and main is at 445a0c28.

Preliminary reading (a reading, not a ruling — the diagnosis belongs to the lane)

This has the shape of the "入队与落地 A" generated-artifact hazard, not a flake. Four readings:

  1. Lint &amp; Type Check was success on this PR's own head 404f69a2 at 16:18:12Z. It only fails against the queue's merged base — so nothing on the branch is independently broken.
  2. packages/spec/export-origins/** is routed merge=os-regen in .gitattributes (read live, not copied). That driver merges exit-0 with zero conflict markers and can silently drop one side; only regeneration exposes it.
  3. The artifact did not exist on this PR's base. packages/spec/export-origins/ was introduced by test(spec): export-surface pins compare a build-time baseline instead of running tsc (#4796) #7090 (f5a9bc2f, "export-surface pins compare a build-time baseline instead of running tsc (flaky: spec/src/cloud/tenant.test.ts 的 #4739 导出面用例贴着 5s 超时 —— 今晚已两次把不相干的 PR 踢出合并队列 #4796)") at 16:06Z. This PR's base is 0bffdae, cut before that. Between the two bases: git diff --stat 0bffdae 445a0c28 -- packages/spec/export-origins/ = 16 files, +5095, moved by test(spec): export-surface pins compare a build-time baseline instead of running tsc (#4796) #7090 / fix(spec,lint): a formula field in searchableFields is refused loudly (#6674) #7103 / feat(spec,drivers,objectql,analytics,formula): $icontains reaches every JS evaluation face (#6520) #7123 / test(spec): the ADR-0010 envelope gate covers UNREGISTERED_KIND_SCHEMAS (#6931) #7116 / feat(spec): the error-code ledger states its federation contract; makeApiErrorSchema(extraCodes) (#4805) #7110 / fix(spec): validate action param defaultValue against the param's own value contract (#6970) #7126.
  4. This PR adds public spec exports (the ctx.dispatch marker and its types). So the merged tree carries main's baseline, generated from a source that does not contain this PR's exports — which is exactly what "the source resolves differently" reports.

Recommended action

A re-run will not help. A re-run reuses the original merge ref (SKILL note 5); the regeneration is missing from that ref, so it will fail identically. This needs a new commit.

Four-step per "入队与落地 A":

  1. git merge origin/main (⛔ no rebase, no force-push)
  2. git checkout origin/main -- the os-regen paths — read the list live with grep os-regen .gitattributes, ⛔ don't work from a copied list
  3. commit the merge first, then regenerate wholesale (⛔ never run gen:schema while in MERGE state — os-regen 驱动指示的 gen:schema 在 merge 未 commit 时运行,会把 authorable-surface 锚点倒退回旧 merge-base —— 生成器写入、门全绿、静默撤销 main 的锚点推进 #5370). For this gate: pnpm --filter @objectstack/spec gen:export-origins, then read the diff: a name whose origin moved file is a re-homed declaration; a name that gains an entry with a different origin is the spec 同名双源:两个 MetadataWatchEvent 形状不同、分挂两个子路径入口,其中 kernel 版零消费方(ADR-0049 enforce-or-remove) #4411 dual-source trap, and the fix is at the declaration (check:dual-source-exports), not in the artifact
  4. assert the sibling PRs' entries are still present, then push

Scope and yield

⛔ Steward authority is landing-only: no merge, no ready/draft flip, no re-queue, no code, no claim changes. Re-queueing after the fix is the lane PM's call.

让行 check: no lane comment on this PR in the last 30 minutes (latest comments are the Vercel and docs-drift bots, 15:51Z / 16:18Z) ⇒ no yield; no double-handling risk.


Generated by Claude Code

…main (#6966)

Two generated artifacts move with the merge:

- `export-origins/` is new on main (#4796) and had never seen `HookDispatch`.
- `api-surface/` needed a real regeneration, not just my own entry. The merge
  of main into this branch produced a file byte-identical to THIS branch's
  side, silently dropping the five exports #7123 added on main
  (`SEARCH_VIRTUAL_TYPES`, `foldAsciiCase`, `asciiCaseInsensitiveContains`,
  `asciiCaseInsensitiveRegexSource`, `isVirtualSearchField`). These paths carry
  `merge=os-regen` in .gitattributes precisely so a merge regenerates rather
  than picks a side; it did not here, and `check:api-surface` is what caught
  it. Regenerating yields the union — both main's five and this branch's
  `HookDispatch`.

Read the export-origins diff as its gate asks: one line, `HookDispatch` under
`src/data/hook.zod.ts`, the same origin as `HookContext`. Not a re-home, not a
new dual-source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK

Copy link
Copy Markdown
Contributor Author

Dequeued on CI_FAILURE, now fixed — all 26 checks green on cb1b66f, mergeable_state: clean, branch level with main. It was not re-queued automatically, so it needs a re-queue from whoever owns that call; I have not enabled auto-merge on my own initiative.

The failure was mine, not a batch-mate's (the queue ref was pr-7101 alone). Two things had drifted since this branch's base, and the second is worth a reviewer's attention because it touched another PR's work:

1. check:export-origins is a new gate. It landed on main in #7090 after this branch was cut, so export-origins/data.json had never seen HookDispatch. Regenerated; the diff is one line, origin src/data/hook.zod.ts — the same file HookContext is declared in, so not a re-home and not a new dual-source (the two readings its gate asks for).

2. Merging main silently dropped five exports that #7123 had added. packages/spec/api-surface/** carries merge=os-regen in .gitattributes precisely so a merge regenerates the artifact instead of picking a side. That did not happen on this merge — the merged file came out byte-identical to this branch's side, deleting SEARCH_VIRTUAL_TYPES, foldAsciiCase, asciiCaseInsensitiveContains, asciiCaseInsensitiveRegexSource and isVirtualSearchField from the recorded surface:

@@ -476,7 +477,6 @@   (merge result vs. the main parent)
     "SEARCH_AUTO_EXCLUDED_TYPES (const)",
-    "SEARCH_VIRTUAL_TYPES (const)",

check:api-surface is what caught it. Regenerating from the merged tree yields the union — #7123's five plus this branch's HookDispatch — and check:generated now reports all 11 artifacts current. Worth knowing that the os-regen driver was configured in the worktree and still did not run on this merge; if that reproduces, any branch merging main can quietly revert a generated baseline, and only the gate stands between that and main.

Also re-measured on the merged tree after rebuilding the closure: objectql 2833, plugin-sharing 418, service-storage 324, spec 9347, all passing. (Three objectql files failed on the first post-merge run — #7012, #6190 and the $icontains conformance — purely from stale dependency dist/; they pass after turbo run build.)


Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit fc3a36a Aug 9, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

2 participants