Skip to content

fix(sharing): stamp organization_id on every sys_record_share write, backfill the stranded rows, admit the object to the tenancy ledger (#14484) - #14726

Merged
os-sales merged 14 commits into
mainfrom
claude/issue-14484-record-share-org-stamp
Sep 3, 2026
Merged

fix(sharing): stamp organization_id on every sys_record_share write, backfill the stranded rows, admit the object to the tenancy ledger (#14484)#14726
os-sales merged 14 commits into
mainfrom
claude/issue-14484-record-share-org-stamp

Conversation

@claude

@claude claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #14484

What this lands

Maintainer ruling 2026-09-02 (decision batch #11 item 3, A adopted — verbatim 「#13564 转维护者处理;其他同意」, recorded at 14484#issuecomment-5511797642): sys_record_share is tenant-scoped, writer-repaired, and the rows written before the repair are backfilled from the record they grant access to. Both halves in one diff, in the ruling's order — writer first, backfill second, ledger row alongside.

  1. Writer (packages/plugins/plugin-sharing/src/sharing-service.ts). SharingService.grant stamps organization_id on both halves of its upsert. A rule-materialised grant carries the granting rule's organization: SharingRuleService.reconcile / reconcileForRecord now hand grant the rule's own criteriaContext(rule){ isSystem, tenantId: rule.organization_id }, the same context the rule's criteria sweep already ran under (finding: an org-stamped sharing rule's criteria sweep runs unscoped — recipients are org-aware, matched records are not, and per-org materialization (10103) multiplies it #10119) — so the grant lands in the organization whose records the rule was allowed to sweep. A direct grant carries the shared record's organization, read off the column the record's object is actually walled by (resolveTenantFieldName from @objectstack/objectql: ADR-0066 opt-out → declared tenancy.tenantField → injected organization_id), with the acting session's organization as the fallback for a record that carries none (the sys_approval_request writer's ruled shape: subject first, acting context second). The organization rides the write context as tenantId and the row literal — the { isSystem, tenantId } shape the System-context writes land untenanted at RUNTIME, so a single-tenant install keeps re-forking the autonumber scope and minting duplicate business identifiers — the producer #8686's backfill cannot reach (17.0.0 GA) #8844 refusal message prescribes — so the driver's tenant audit is satisfied and the update half lands through the driver's own scope (organization_id = ? OR IS NULL, which keeps a pre-repair NULL row in reach). Nothing resolvable ⇒ an explicit null on the row, for the engine's ruled derive-or-refuse to decide. ⛔ The service's eleven bare-context reads are untouched (measure: census the dependents of the SQL driver's orWhereNull tenant-wall carve-out before deciding its future (NULL org_id rows are globally visible on shared-DB walled deployments) #13564's question, with the maintainer).
  2. Backfill (packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts, new). The backfill-sys-file-organizations.ts shape, per-table and not a sweep: scans WHERE organization_id IS NULL, re-reads each row's record (object_name + record_id) at repair time, stamps the record's organization off its wall column. Dry run first and by default; runSysRecordShareOrganizationBackfill(engine, { dryRun: false }) writes; idempotent by construction (pinned by running it twice). Trigger copied from both precedents: an operator-invoked module, not exported from the index and not bundled into dist (tsup builds src/index.ts) — nothing runs at boot and nothing is scheduled.
  3. Ledger (packages/objectql/src/tenancy/platform-object-tenancy.ts). sys_record_share leaves unclassified as tenant-scoped with the ruling as the cited fact; the admitted-list pin in tenancy-by-object-classification.test.ts gains the entry (the necessary companion of the row — it pins the list, not a count). This is the whole of the cross-domain touch.

Orphans — the implementer's choice, stated

Grant rows whose record no longer exists are left NULL, counted (totals.orphans / residue.recordNotFound) and logged (one warn line per plan, naming the count) — never deleted here. Reason: the invariant "record gone ⇒ the row cannot describe any access" is already owned, by record-orphan-cleanup.ts and the kernel:bootstrapped sweep SharingService.sweepOrphanedRecordShares (#5103), which runs on every boot ahead of the rule-grant passes and deletes exactly this population. A second deleter would be the fork that module exists to prevent (two chunk sizes, two "a failed probe deletes NOTHING" rules). Pinned: the orphan row is present after the run, counted, logged, and the second run re-reports it without writing. Every other underivable row stays NULL and is reported by reason (object has no organization column · object has no readable schema · record read failed · record exists but carries none · unaddressable), for a dry run too.

Premise check (P1–P4), measured on origin/main at c616c2cc2 (the card read at 9e286e248)

  • P1 — still the only writer: holds. git grep -nE "(insert|update|upsert)\(\s*['\"]sys_record_share" over non-test sources: exactly sharing-service.ts:1242 (update) and :1261 (insert). The pre-existing backfillRetiredAccessLevels (sharing-plugin.ts, fix(sharing): 「完全访问」声明了删除/转移/共享,实现上与「编辑」等价 #3865) does engine.update(object, { id, access_level: 'edit' }) on sys_record_share — a one-column normaliser that predates the card's reading and creates no rows; it is not a second writer of the organization and is untouched.
  • P2 — row literal carries no organization_id: holds. The only non-comment organization_id hits in the file were the HierarchyScopeContext.organizationId plumbing (~:1450–1522), not a write.
  • P3 — unclassified: holds. sys_record_share was absent from PLATFORM_OBJECT_TENANCY, i.e. unclassified by absence.
  • P4 — would the ledger row alone suffice? No, and it would break walled installs. SqlDriver.injectTenantOnInsert still fires only on DriverOptions.tenantId; buildDriverOptions still sets it only when execCtx.tenantId !== undefined. Engine.resolveSystemInsertOrganization returns early for an unclassified object; once admitted, an organization-less system insert on sys_record_share is derived only on a single posture with exactly one organization and refused (ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED, 500) on group / isolated — the engine never derives the rule's or the record's organization, it derives the install's. So the writer half is required, and it is also what makes the walled case pass. Pinned in record-share-organization-stamp.test.ts: on isolated and group a bare system insert is refused with that envelope, and the repaired writer's grant sails through carrying the organization.

Pins the ruling names

pin where
a rule-materialised grant carries the rule's organization (the organization-less record deal_p1 is the discriminating row: ORG_A under an ORG_A rule, NULL under a platform-global rule) record-share-organization-stamp.test.ts (real SqlDriver + ObjectQL), sharing-service.test.ts
a direct grant carries the record's organization, not the caller's active one; acting-session fallback for an organization-less record both files
after the backfill on a seeded fixture no row referencing a live record is NULL; twice is a no-op backfill-sys-record-share-organizations.test.ts (fake), record-share-organization-stamp.test.ts (real driver)
the cliff, closed: a tenant-scoped read under plugin-security's real computeTenantLayer0Filter returns the same grants the bare-context read returns for that organization (isolated and group), and the two organizations' reads are disjoint; the control case shows the legacy NULL row on the bare read and on no tenant read packages/plugins/plugin-security/src/record-share-tenant-wall.test.ts (new; plugin-security owns the wall and already devDepends on plugin-sharing) — the backfilled-rows half composes Layer 0's isolated predicate into where exactly as andComposeLayers does, in the plugin-sharing backfill test
orphans: left NULL, counted, logged, never deleted; re-reported on the second run backfill test (fake) + real-driver case

Verification (union run on 238211787; re-run unchanged on the post-sync head 1ad15772f — see the last section)

  • pnpm --filter @objectstack/plugin-sharing exec vitest run --maxWorkers=2Test Files 32 passed (32) · Tests 759 passed (759); pnpm --filter @objectstack/plugin-sharing typecheck → exit 0 (check:test-typecheck: OK … 2 file(s) / 3 error(s) / 3 pinned signature(s) — the debt ledger is unchanged).
  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 (whole package, 96 files: ten of its suites consume @objectstack/plugin-sharing from dist/) → Test Files 96 passed (96) · Tests 1790 passed (1790); pnpm --filter @objectstack/plugin-security typecheck → exit 0.
  • objectql, declared narrowing: the ledger row changes engine behaviour only for system writes on sys_record_share; git grep -l sys_record_share over packages/objectql/**/*.test.ts names two files, plus the eight *tenan*.test.ts files that exercise the tenancy machinery → vitest run on those 9 → Test Files 9 passed (9) · Tests 103 passed (103); tsc --noEmit -p packages/objectql/tsconfig.json → exit 0 (after rebuilding @objectstack/types, which the merged main moved).
  • Every exit code above was captured before any pipe (cmd > log 2>&1; EXIT=$?); the verdict lines quoted are the tools' own.

Ablation (two legs, each: mutate → prove on disk → build → prove in dist/ → run → restore → prove restore)

  • Leg 1 — the writer's stamp knocked out (organizationId = null; (globalThis as any).ABLATION_14484_WRITER = true; right after resolution; a // marker was tried first and is not a valid measurement — esbuild strips comments, ablation-dist-preflight reported it absent, so the leg was redone with a marker that survives the bundle). Mutation proven by grep -c = 1 and git hash-object ≠ HEAD blob; pnpm --filter @objectstack/plugin-sharing build then node scripts/ablation-dist-preflight.mjs @objectstack/plugin-sharing ABLATION_14484_WRITER → exit 0. Result: plugin-sharing Tests 15 failed | 125 passed (140) (every organization pin in sharing-service.test.ts and record-share-organization-stamp.test.ts, including the walled-posture "NOT refused" cases, which flip to refused), plugin-security Tests 4 failed | 1 passed (5) (every equality pin; only the control survives). Restore by git checkout HEAD -- ABS_PATH: git diff HEAD --stat empty, blob hash = HEAD blob, marker count 0, rebuild, preflight --absent → exit 0.
  • Leg 2 — the backfill's derivation knocked out (const organization = null as string | null; // ABLATION_14484_BACKFILL, replacing nonEmpty(record[column])). Proven by marker count 1 / original text count 0 / blob ≠ HEAD. No dist/ consumer exists for this module (unexported, unbundled; both suites resolve it from src) — stated, not skipped. Result: Tests 10 failed | 14 passed (24) — every planning / apply / idempotency / report pin, the real-driver backfill case, and the cliff's AFTER case. Restore proven as above.

Gates — derived from the actual change set

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (15 paths vs merge base; 70 commands: 64 by path, 8 by change kind). All 70 were run with exit captured before any pipe:

  • Green (65) — including the dispatch-named families: check:system-context-census (page re-anchored with pnpm gen:system-context-census; --fix refused zero files, every change a pure line shift), check:tenant-audit-census (node scripts/tenant-audit-census.mjs --write re-derived the ledger — one row arrives: the backfill's update on sys_record_share, classified elevated; the page's nine hand-written prose figures restated at the new population, as the gate's own contract requires), check:engine-double-contract (two new doubles pinned with --write), check:objectql-double-limit, check:doc-authoring, check:cross-package-test-inputs, check:test-source-alias, check:durability-log-level, check:adr-0087-registration, check:changeset-no-major, check:empty-changeset, check:skill-examples (after building the @objectstack/client-react closure it reads), check:type-check-coverage, and the docs/spec families.
  • NOT MEASURED (5) — each by the gate's own text, none a red: check-engine-split-ratio (exit 2: shallow clone, the 90-day window is not covered; "refusing rather than printing a caveat"), check-test-completeness (exit 3: needs a saved turbo run test log — "not a red, nothing to fix"), check:dual-build-cjs-loads (exit 3: "PREREQUISITE NOT MET — 52 packages have no dist/"), check:type-check-debt (exit 3: "build the closure first … nothing was measured"), check:i18n (exit 1 with "Nothing was checked" — its configs resolve through unbuilt dist/). All five run in CI's Lint & Repo Gates on a full build.
  • Two gates first went red on my diff and were fixed, not routed around: check:doc-authoring (tracker ids inside operator-facing strings — moved to adjacent // comments) and check:objectql-double-limit (my backfill test's find double was UNJUDGED under the probe — reshaped to the sys_file precedent's double, with the tenant read composing Layer 0's predicate into where).

Fixture triage

rule-criteria-org-scope.test.ts hand-builds the sys_record_share table from a field list that omitted the organization_id column the registry provisions on every platform object, so the stamped insert hit no column named organization_id. Disposition: 补声明 — the fixture was never spec-faithful, the omission just had no reader. Consumption-radius sweep: exactly two hand-built real-driver sys_record_share fixtures exist in the repo (git grep over **/*.test.tsinitObjects|new SqlDriver); the other, read-scope-provenance-mark.test.ts, already declares the column.

Clause ②: yes — declared by the ruling (tenant-boundary write change), re-derived from the diff

$ git diff -U0 origin/main...HEAD | grep -E '^\+\s*export '
+export const SYS_RECORD_SHARE_BACKFILL_OBJECT = 'sys_record_share';
+export interface SysRecordShareBackfillEngine {
+export interface SysRecordShareBackfillLogger {
+export interface SysRecordShareSubject {
+export interface PlannedSysRecordShareRow {
+export interface SysRecordShareBackfillResidue {
+export interface ResidualSysRecordShareRow {
+export interface SysRecordShareBackfillReport {
+export interface SysRecordShareBackfillOptions {
+export async function planSysRecordShareOrganizationBackfill(
+export async function applySysRecordShareOrganizationBackfill(
+export async function runSysRecordShareOrganizationBackfill(
+export function formatSysRecordShareOrganizationBackfillReport(
(13 lines)

All 13 are in the unexported, unbundled backfill module — zero additions to any package's published entry point, and no packages/spec contract moved (GrantShareInput / RecordShare are unchanged; the organization reaches grant through ExecutionContext.tenantId, the field every transport already fills). The mechanical derivation therefore under-states the tier: what makes this Clause ② yes is the behavioural contract — every sys_record_share write now carries a tenant, the engine refuses an organization-less system write on it under a wall, and SharingRuleService threads the rule's organization into the grant. The declaration stands; needs:contract-review is on this PR and on the card, and ⛔ neither is cleared here — both are the PM seat's after the isolated contract review.

Changeset

.changeset/record-share-organization-stamp.md: @objectstack/plugin-sharing minor (a behaviour change on a published writer plus a new operational module — graded, not skipped, because the release notes are where an operator learns the backfill exists and that the dry run comes first), @objectstack/objectql patch (one ledger row; the engine's ruled derive-or-refuse now covers sys_record_share). Not declared-breaking: no authorable key, export or config field is removed or renamed, so no ADR-0087 disposition is owed (check:adr-0087-registration green).

Scope fences honoured

  • ⛔ B and C (rule the object org-less / stamp forward only) not implemented. ⛔ The sweep covers sys_record_share and no other table — the scope pin is a named constant with the ruling in its doc. ⛔ The 11 bare-context reads in the service are unchanged. ⛔ content/docs/permissions/system-context.mdx was never hand-edited: origin/main merged and committed first, then pnpm gen:system-context-census on the merged tree, with the gate as the proof. ⛔ fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547) #14572's files were not edited except sharing-rule-service.ts at the four grant call sites (+ one doc paragraph), measured disjoint from its hunks (:137, :1085, :1118, :1123 vs reconcile at ~:1230+) and declared as a claim amendment on the card (14484#issuecomment-5515460939) before the edit; its residual sys_business_unit_member is unadjudicated in PLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570 is untouched. ⛔ No content/docs/releases/**, no skills/**, no docs/adr/**.
  • Out of scope, not addressed here: whether RecordShare (spec contract) should surface organization_id to cross-package callers — the column is on the row and readable through the engine; the contract projection is a separate decision. Also #11611 remains open — the general "platform tables and organization_id" question this card is one instance of.

Post-open sync (coordinator readings, 2026-09-02)

origin/main merged again (13 commits, 809d41704) through scripts/pm/os-regen-merge.sh. Its step 3 refused the ordinary commit right after the merge — the designed collection point — because content/docs/permissions/system-context.mdx was deferred by the os-regen driver; the whole chain was then re-derived on the merged tree: pnpm gen:system-context-census (8 re-anchors, --fix refused zero files), node scripts/tenant-audit-census.mjs --write, and node scripts/check-engine-double-contract.mjs --write for the three-way-contended scripts/engine-double-contract.pinned.json (0 added or grown, 0 lost — never hand-edited). All three gates green on the result: check-system-context-census: OK — 109 elevation read sites … all anchored, check-tenant-audit-census: OK -- 218 write call sites certified, check-engine-double-contract exit 0. Head is now 1ad15772f.

Fence readings honoured. sharing-rule-service.ts is co-held by PR #14572: my hunks sit at :1014 / :1267 / :1284 / :1340 / :1357 (origin/main numbering), theirs at :136 / :1085 / :1118 / :1123 (+49); nearest pair 71 lines apart, regions disjoint, and nothing here needs the :1085–:1172 band — if it ever did, that would be a stop-and-report, not a merge.

Docs drift — re-derived, triaged, zero edits

node scripts/docs-audit/affected-docs.mjs --json origin/main on the merged head lists 30 pages = 25 hand-written + 5 release-owned (60 anchors: 68 symbol, 2 route, 3 sdk, 8 literal; 6 test files excluded). Criterion applied: does this change make a sentence on the page false? — this card changes the tenant attribution of sys_record_share rows (who sees a grant under a wall, whether a grant row carries an organization, what legacy rows look like after the backfill). Only pages that state those facts needed reading.

  • Listed by a generic symbol, not read, not changed (18): client-sdk, data-api, error-catalog, wire-format, webhooks, architecture, fields, import-mappings, metadata-service, storage-service, audit-service, authentication, record-view-auditing, http-protocol, i18n-standard, realtime-protocol, knowledge, state-machine, ui/pages — anchored on dryRun / pageSize / maxRows (fields of the new backfill options that happen to share names with the import route's parameters), object_name / record_id (the pointer-pair columns), or getSchema. A grep for record-share statements returns nothing on any of them; a secondary sweep for "sharing × organization/tenant" on one line hit only webhooks.mdx:324 (payload scrubbing, unrelated).
  • Read, because they name sys_record_share (6): data-modeling/objects.mdx:280 (engine-owned list — "written only by their owning service under a system context": still true, the writer still writes under a system context, now carrying tenantId); permissions/permissions-matrix.mdx:159,342 (manual sharing = source: 'manual' rows: still true); permissions/sharing-rules.mdx:137,183,207 (rule materialises source: 'rule' rows keyed by source_id: still true; its organization prose is about rule reads, OWD and the Layer 0 wall, none of it about grant rows); plugins/packages.mdx:102,379 (ownership lists); protocol/objectql/security.mdx:420,442 (enforcement status, share-manager); permissions/system-context.mdx:137 (row 36: the read surface is not self-scoped — the reads this card leaves to measure: census the dependents of the SQL driver's orWhereNull tenant-wall carve-out before deciding its future (NULL org_id rows are globally visible on shared-DB walled deployments) #13564; the page is generated-anchored and never hand-edited). No sentence on any of them is falsified: none states whether a grant row carries an organization or what a tenant-scoped read of the table returns.
  • Outside the mapper's list, swept deliberately: any hand-written page enumerating the design: isSystem 写入是否在租户审计控制范围内?——#13178 类级装置(A/B/C)的共同前置,从未被裁过 #13491 admitted set (the one sentence class the ledger row would falsify) — grep over content/docs minus references/ and releases/ for PLATFORM_OBJECT_TENANCY / platform-object-tenancy / sys_notification_delivery / unclassified finds no enumeration (the two unclassified hits are an "unclassified surface" CI note in authorization.mdx and unrelated prose in realtime-protocol.mdx).
  • Release-owned (5, read-only, untouched): v12 (no record-share mention), v14:52 (the recipient_type rename), v15:1003 (seed-loader history), v16:252 (a list), v17:54,1478,2029,3609,4291 (read-surface self-scoping, positions). None states grant-row tenancy, so this change introduces no factual error on them — nothing for a docs-only card.

Merge round (2026-09-03)

Dequeued at 01:16:56Z with MERGE_CONFLICT. No behaviour changed in this round: the branch's own source delta against its merge base is byte-identical before and after it (git diff BASE HEAD -- packages/ hashes to a07d7a95260dd4de9d23c681a7c2042f00d2477b on both sides), and the branch's two engine-double-contract pin rows survive verbatim.

What conflicted, and why none of it is resolvable by hand. origin/main had moved to dee4dd4ba — PR #14718 landed plugin-security/src/claim-seed-ownership.ts, PR #14712 added a suspended-run-store delete site. Two generated census artefacts collided:

  • content/docs/permissions/tenant-audit-census.mdx
  • docs/audits/2026-08-tenant-audit-write-call-sites.counts.md

Both conflicts fell entirely inside the deliberately-unenforced corpus-scale block and its dated marker. The load-bearing part merged cleanly and wrong, exactly as that ledger's own header warns it would: both sides' rows landed (main's suspended-run-store.ts delete at count 3, this branch's backfill-sys-record-share-organizations.ts update row) while the totals block still read 218. A third artefact, content/docs/permissions/system-context.mdx, is routed to merge=os-regen; the driver merged it with exit 0 while silently keeping one side.

Resolved by regeneration, never by hand, in the order the runbook fixes:

  1. git merge origin/main. The two conflicts were resolved by taking main's side wholesale — the content was about to be overwritten, so the side is irrelevant — and git restore --source=origin/main (worktree only) took main's side of the one os-regen path both sides had moved.
  2. The merge was committed first, as 98b1cf0b7. The os-regen pre-commit hook recorded the deferral rather than passing it: os-regen: this is a MERGE commit — the 1 stale artifact(s) above are DEFERRED to the next commit, not passed.
  3. Only then was the tree regenerated: node scripts/tenant-audit-census.mjs --write (it owns both tenant-audit artefacts) and pnpm gen:system-context-census. scripts/engine-double-contract.pinned.json was re-checked and needed no regeneration.
  4. The regeneration was committed separately, as 8344d31b5, which discharged the deferral: os-regen: deferred regeneration discharged — all artifacts current, marker cleared.

What the generator did to each file. The census moved to the joint population — 218 → 219 write call sites (147 statically decidable, 147 against a tenancy-enabled object, 135 threading a tenant context, 101 decidably elevated) — keeping both sides' rows rather than either side's totals. The nine hand-written prose figures that check B holds to the census followed it; that check has no repair arm by design, and the page says so. system-context.mdx had 8 anchors re-derived onto the merged tree's real line numbers in plugin-sharing/src/sharing-service.ts: :654→:677, :920→:943, :1007→:1030, :1208→:1238, :1286→:1476, :1311→:1501, :1338→:1528, :1597→:1787. That is the driver's silent drop being repaired, and it is visible only because the regeneration ran on a committed merge.

Gate verdicts on the merged tree (every exit code captured before any pipe):

  • check-tenant-audit-census: OK -- 219 write call sites certified (147 decidable; 9 tenancy-enabled sites PROVABLY carry no tenant context, 32 more unreadable), 23 prose figures held to the census.
  • check-tenant-audit-census self-test: 19 cases pass and tenant-audit-census self-test: 18 cases pass
  • check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read. (self-test: all cases passed)
  • check-engine-double-contract: OK — 761 pinned, 133 in the DEBT ledger, 3 exempt.
  • pnpm --filter @objectstack/plugin-sharing testTest Files 32 passed (32) · Tests 769 passed (769)
  • The full derived family, re-run after the final commit at 8344d31b5: dispatch-gates --commands: 70 command(s)64 green, 6 NOT MEASURED, zero findings. All six are prerequisite refusals by their own verdict text: check-engine-split-ratio (exit 2, shallow clone inside the 90-day window), check-test-completeness (exit 3, needs a saved CI test log), check:skill-examples (exit 1, packages/client-react/dist unbuilt), check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET), check:i18n (exit 1, Nothing was checked), check:type-check-debt (exit 3, closure unbuilt). All six run green in CI on a full build.

Mergeability confirmed against a freshly fetched origin/main (89a156af8, two commits newer than the one merged): git merge-tree --write-tree origin/main HEAD exits 0 with zero CONFLICT lines. Those two newer commits leave both census artefacts untouched on main, so the population does not move again.

Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…r row; backfill module

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
… backfill legacy rows, admit the object to the tenancy ledger

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
… on the merged tree (#14484)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…he backfill test double the precedent's shape (#14484 gates)

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

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/plugin-sharing, touching 63 documentable anchor(s).

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

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

What this run could not see
  • 21 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 20 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 dee4dd4ba33f8b98ff90e240327b7567adecc017packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8276750644960e28c1bad2cf743d543af3cff69a — the merge of head 8344d31b5c7de3b802f85b0a99b144d83544bf87 into base dee4dd4ba33f8b98ff90e240327b7567adecc017, 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 8276750644960e28c1bad2cf743d543af3cff69a && git checkout 8276750644960e28c1bad2cf743d543af3cff69a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dee4dd4ba33f8b98ff90e240327b7567adecc017 8344d31b5c7de3b802f85b0a99b144d83544bf87 && git checkout -B drift-repro dee4dd4ba33f8b98ff90e240327b7567adecc017 && git merge --no-ff 8344d31b5c7de3b802f85b0a99b144d83544bf87

node scripts/docs-audit/affected-docs.mjs --json dee4dd4ba33f8b98ff90e240327b7567adecc017

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

os-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

In-seat Clause-② contract review — VERDICT: FAIL. One blocking finding, and it is a real one.

Reviewer: isolated subagent, fed only the card, the ruling and this PR. Model served, verified from the transcript rather than from self-report — grep -o '"model":"[^"]*"' over the run: 90 of 90 claude-fable-5-1, no other model. The verdict is adoptable and is adopted verbatim below. ⛔ Nothing in it is softened, re-ordered or paraphrased by this seat.

The reviewer was told mid-run that the head had moved and re-anchored its readings itself; the head-comparison work in ① is its own.


VERDICT: FAIL

① Head anchoring: readings were first taken on 238211787; the PR moved to 1ad15772f (merge of origin/main@809d41704 as e8666e3b1, then one regeneration commit). I re-fetched and byte-compared every substantive file across the two heads by blob id (git rev-parse <head>:<path> on both) — sharing-service.ts, sharing-rule-service.ts, backfill-sys-record-share-organizations.ts, platform-object-tenancy.ts, tenancy-by-object-classification.test.ts, the three new test files, rule-criteria-org-scope.test.ts, sharing-service.test.ts, the changeset and engine-double-contract.pinned.json: all SAME. git diff --stat origin/main...refs/remotes/pr/14726 on the new head (merge-base 809d41704; origin/main is now 4d0d9445a, whose three further commits touch none of the PR's 15 files) is the same 15 files / +2027 / −38. The regeneration commit 1ad15772f touches exactly two gate-owned files and changes only the generator's own Measured on 2026-09-02 at `bd4837965` `e8666e3b1` stamp (the merge commit's sha) inside the BEGIN/END GENERATED: tenant-audit-census block — the signature of a re-run, not a hand edit; system-context.mdx's diff is line-number re-anchors only (rows 31–35, no prose change). Gate runs themselves NOT MEASURED.

Scope, held. Both halves are in one diff: the writer (sharing-service.ts grant, both upsert halves, plus the four grant call sites in sharing-rule-service.ts reconcile/reconcileForRecord now passing this.criteriaContext(rule){ ...SYSTEM_CTX, tenantId: rule.organization_id } when the rule carries one, bare otherwise), the backfill (new module, SYS_RECORD_SHARE_BACKFILL_OBJECT = 'sys_record_share'), and the ledger row (platform-object-tenancy.ts adds sys_record_share: { tenancy: 'tenant-scoped', … }; the admitted-list pin gains the entry). Commit 1 lands writer + ledger + backfill together; tests follow. Per-table: the only write verbs added in non-test sources are engine.update(SYS_RECORD_SHARE_BACKFILL_OBJECT, …) in the backfill and the two sys_record_share writes in grant; subject objects are read only. The 11 bare reads: every context: SYSTEM_CTX read site I can grep on main (10 by my grep: 449, 526, 548, 737, 932, 1229, 1289, 1297, 1319, 1345) survives unchanged at shifted lines; one bare read is ADDED (recordOrganization at :1369, of the shared record, not of sys_record_share). The pins the ruling names are each present (see TESTS). The P4 claim is TRUE from the tree: Engine.resolveSystemInsertOrganization returns early only if carriesOrganization(execCtx.tenantId), or isPlatformObjectOutOfTenantAuditScope(object) (false once the row is tenant-scoped), or every row carries the column; otherwise resolveSystemWriteOrganization returns { kind: 'refuse', reason: 'walled-posture' } for any posture ≠ single and the engine throws SystemWriteOrganizationRequiredError. The pre-PR writer carried neither, so the ledger row alone would refuse every sys_record_share insert on group/isolated — the two halves are mutually load-bearing, as the author says.

② Derived on the new head: git diff -U0 origin/main...refs/remotes/pr/14726 | grep -E '^\+\s*export ' → 13 lines, all in packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts: SYS_RECORD_SHARE_BACKFILL_OBJECT, SysRecordShareBackfillEngine, SysRecordShareBackfillLogger, SysRecordShareSubject, PlannedSysRecordShareRow, SysRecordShareBackfillResidue, ResidualSysRecordShareRow, SysRecordShareBackfillReport, SysRecordShareBackfillOptions, planSysRecordShareOrganizationBackfill, applySysRecordShareOrganizationBackfill, runSysRecordShareOrganizationBackfill, formatSysRecordShareOrganizationBackfillReport. Reachability: packages/plugins/plugin-sharing/src/index.ts is untouched by the diff and does not reference the module; package.json exports is "."dist/index.* only, files is dist only, and tsup.config.ts entry: ['src/index.ts'] — the 13 symbols reach no published entry and are not in the tarball. SharingRuleService/SharingService public signatures are unchanged; the organization travels on ExecutionContext.tenantId. Shape judgment: the unexported operator module is the shape of both in-tree precedents (I verified service-storage/src/index.ts does not export backfill-sys-file-organizations.ts either); a future export * from the barrel would leak all 13 at once, which is the precedents' accepted risk, not a new one. Changeset: plugin-sharing: minor is right (behaviour change on a published writer; no shape change). objectql: patch under-grades — the row flips a walled install from silent NULL to a 500 refusal for any third-party sys_record_share writer, and the #13491 admission of the first seven rows shipped as minor (.changeset/tenant-audit-scope-by-object-classification.md, still pending, so both ride one train — moot in effect). ADR-0087: nothing removed or renamed, no breaking declaration, no BREAKING token in the changeset body → no disposition marker owed. Log levels: no .error(/.fatal( is added anywhere in the diff (grep, new head); the one new site on the published SharingServiceOptions.logger sink uses warn (the required member), and the backfill's logger is a new structural, unpublished interface with warn required and no error member — the #13592 ruling is honoured.

③ Rule-materialised grant: carries the rule's org via criteriaContext (verified body at sharing-rule-service.ts:1016) — the ruled answer; a platform-global rule (null org) falls back to the record's org, then to the engine. Direct grant: record's org first, read off resolveTenantFieldName(schema) (the driver's own column precedence), acting org as fallback. Walled vs single: single with one org derives the install org; single with several orgs refuses ambiguous-organization; group/isolated refuse. The update half is now driver-scoped (applyTenantScopeorganization_id = ? OR organization_id IS NULL, sql-driver.ts:12066ff), so a legacy NULL row is reachable and repaired in place (pinned on a real SqlDriver). Backfill: scan is WHERE organization_id IS NULL paged by id and read in full before any write; derives from the referenced record only (never recipient/granter/rule); orphans left NULL, counted, one warn, never deleted — the deleter is the existing sweepOrphanedRecordShares at kernel:bootstrapped (sharing-plugin.ts:881ffsweepOrphanedRowsByRecordExistence), verified; idempotent by construction; single posture inert (computeTenantLayer0Filter returns null). What breaks it: the direct-grant path stamps a guessed organization on a read failure — see BLOCKING 1.

TESTS: record-share-organization-stamp.test.ts (real SqlDriver + ObjectQL) pins: ORG_A rule → every grant ORG_A including org-less deal_p1; platform-global rule → record's org, deal_p1 NULL; per-record hook pass; direct grant with no ctx org → record's org; org-less record + ORG_B session → ORG_B; update half repairs a NULL row; backfill writes 2/4, leaves orphan + org-less NULL, one warn, second run planned: 0, written: 0; on isolated/group a bare system insert rejects with ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED/500 while the repaired writer's grant lands. sharing-service.test.ts (double) pins: record's org over caller's; { isSystem, tenantId } on the write context; no-tenant-column → caller's org; nothing → explicit null + undefined tenantId; system ctx org wins over record's; update never clears a stored org; read failure with NO session org → null + warn. backfill-…test.ts (double, assertEngineUpdateDispatch-checked) pins dry-run default, six residue reasons, orphan logged once per plan-then-apply, per-row failure recorded, idempotency, report text, and BEFORE/AFTER under a hand-composed isolated predicate. record-share-tenant-wall.test.ts (plugin-security; real computeTenantLayer0Filter, storage double) pins tenant read == bare read filtered by org on isolated and group, disjointness, and the control cliff. Not pinned: the read-failure path WITH a session org (the only variant where a wrong stamp is possible — the existing test's title "never a stamped guess" over-claims); any end-to-end tenant-scoped read through the real security middleware over a real driver (the cliff pins are predicate-level with a storage double, and compare engine.find, not SharingService.listShares); the walled-install reconcile abort on a platform-global rule × org-less record; the scoped-update no-op when the existing row carries a different non-null org. Test execution and all gate runs: NOT MEASURED (read only).

BLOCKING:

  1. packages/plugins/plugin-sharing/src/sharing-service.ts (identical on both heads): recordOrganization (:1361) returns the same null for "record carries no organization" and for "the read threw" (its catch logs warn and returns null), and resolveDirectGrantOrganization (:1339, the ?? activeOrganizationId(context) at :1344) then stamps the acting session's organization on the row and threads it as tenantId. On a transient read failure for a record that HAS an organization, a direct grant is therefore written into the caller's active org, which can differ from the record's (single with several orgs — Layer 0 inert, owner check reads under SYSTEM_CTX; group — a multi-member owner active in a sibling org). That is a wrong stamp on a permission-boundary column: the grant is invisible to the record's org under a tenant-scoped read and visible to the sibling's, the backfill cannot repair it (not NULL), and only a warn marks it — whose text ("the engine's system-write organization rule decides the row") is false on this path. The code's own doc comment on recordOrganization ("a read that did not happen must not pass for a record with no organization … never a silently stamped guess") states the property the implementation lacks, and System-context writes land untenanted at RUNTIME, so a single-tenant install keeps re-forking the autonumber scope and minting duplicate business identifiers — the producer #8686's backfill cannot reach (17.0.0 GA) #8844 — cited as the rule this ledger admission switches on — holds that guessing a tenant is strictly worse than refusing. Fix shape (not prescribed): make the failure distinguishable from absence so the direct path yields null-to-engine (or refuses) on a failed read, and pin the with-session-org variant.

§5 NOTES:

  1. The changeset's sentence "no shipped path meets that refusal" is not accurate: a platform-global rule whose sweep (bare SYSTEM_CTX, unscoped) matches an organization-less record on a walled install now resolves null → the engine refuses → reconcile has no per-grant catch, so that rule's reconcile aborts mid-loop (partial grants, stale rows not revoked); the boot backfill warns per rule and the hook path catches. This is the ruled refuse-loudly direction, but the walled-install delta is unstated in the release note.
  2. @objectstack/objectql: patch under-grades a behaviour change the changeset body itself describes (silent NULL → 500 refusal on walled installs); precedent for admission rows is minor (design: isSystem 写入是否在租户审计控制范围内?——#13178 类级装置(A/B/C)的共同前置,从未被裁过 #13491). Practically moot on this train.
  3. Scoped update no-op: with the update half now driver-scoped, a row already stamped with a different non-null org is unreachable; grant still returns { ...row, ...patch } and reconcile counts updated += 1. Reachable when an org-less record is granted by rules from two different orgs (upsert key excludes source_id) or a record is re-homed; self-heals only through the other rule's next revoke. Acknowledged in the code comment; not pinned.
  4. Writer and backfill disagree on organization-less records under an org-scoped rule: the writer stamps the rule's org; the backfill leaves the legacy row NULL (recordHasNoOrganization) and the rule's reconcile does not touch it while access_level is unchanged. This follows the ruling's derive-from-the-record order and is reported by reason — flagged so the residue is not misread as "nothing to do".
  5. One bare-context read is ADDED (recordOrganization, of the business record); the system-context census page shows only anchor shifts, which is consistent with the generator's output but the gate's acceptance of the new site is NOT MEASURED.
  6. check:engine-double-contract, check:objectql-double-limit, check:tenant-audit-census, check:system-context-census, check:durability-log-level, check:adr-0087-registration, check:test-source-alias, and all vitest/typecheck results: NOT MEASURED here; only their inputs were read.

The seat's dispositions

BLOCKING 1 is the patch round, and it is the finding the whole review was for. A transient read failure on a permission-boundary column does not fail closed here — it fails sideways, into the caller's organization. Everything that would normally contain such a mistake is absent by construction: the row is not NULL so the backfill cannot repair it, a tenant-scoped read makes the grant invisible to the org that owns the record and visible to a sibling, and the only trace is a warn whose text is false on that path. And the file's own doc comment already asserts the property the code does not have — the design intent was right and the implementation drifted from it. ⛔ The fix must make failed read distinguishable from no organization, and must pin the with-session-org variant, which is the one case today's suite never exercises.

§5, disposed one by one. Notes 1, 2 and 3 ride patch round 1: note 1 is this PR's own changeset sentence, and an inaccurate release note about a walled-install behaviour change is exactly the sentence an operator reads before upgrading; note 2 is a one-token level change and the precedent (#13491 shipped its admission rows as minor) settles it — "moot on this train" is a reason to make it free, not a reason to leave it wrong; note 3 is a reachable silent no-op that this PR introduces and that reconcile counts as an update, so it gets a pin even though the behaviour stands. Note 5 is a gate run the reviewer could not do: confirm check:system-context-census accepts the added bare-context read and quote its verdict line. Notes 4 and 6 need no action — 4 is the ruling's own ordering, correctly reported by reason; 6 is a boundary declaration.

Not reopened: ① scope, ② Clause-② and the changeset shape for plugin-sharing, and the substance of ③ — including the two halves being mutually load-bearing, which the reviewer verified from the tree rather than taking from the author. The design stands.

Both carriers stay hung on this PR and on #14484. This PR stays draft. Neither is cleared until a delta review — the same reviewer, resumed, on the patch head — returns PASS.

Scheduling, stated so the wait is visible: this seat is at the maintainer's dispatch cap of 3 running dev agents. The patch round is queued behind PR #14528's merge round, which was already declared first in line at 20:56Z. Nothing is being dispatched ahead of either.


Generated by Claude Code

…organization on a direct grant (#14484)

Patch round 1 of the 2026-09-02 contract review (BLOCKING 1): `recordOrganization`
returned the same `null` for "the record carries no organization" and for "the
read threw", so `resolveDirectGrantOrganization` fell through to the acting
session's organization on a transient read failure — a wrong stamp on a
permission-boundary column that the backfill (`WHERE organization_id IS NULL`)
could never repair.

The reading is now a three-way `RecordOrganizationReading` (`organization` /
`none` / `read-failed`). The direct path substitutes the session's organization
for `none` only; `read-failed` yields `null` to the engine's ruled
derive-or-refuse (#8844). The warn text now states what the failed read does
NOT do. Pinned on the double (insert half, update half, and the `none` control)
and on a real SqlDriver + ObjectQL: `single` with no organization -> NULL,
`single` holding two organizations -> refused as ambiguous, `isolated`/`group`
-> refused, plus the read-working control on both.

Review §5: note 3 pinned (the scoped update half's silent no-op on a row
stamped with another organization — `grant`'s return value, the two-rules
`grantsCreated` path and the re-homed-record `grantsUpdated` path); note 1 —
the changeset now states the two shipped paths that meet the walled-install
refusal (platform-global rule x organization-less record, with the mid-loop
reconcile abort; and a failed direct-grant read); note 2 — objectql graded
`minor`. Census page re-anchored with `--fix` (8 line-rot anchors, 0 refused).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…RECORD_NOT_FOUND abort (#14484)

CI on the previous head measured two things the first spelling of these pins
assumed wrongly.

1. A plain `{ userId, tenantId: ORG_B }` session never reaches an ORG_A record
   through `grant`: `assertCanManageShares` -> `isRecordVisible` reads under
   the CALLER's context and the driver scopes it to `organization_id = :active
   OR IS NULL` on every posture. The reachable wrong-stamp shape is the `group`
   one — a multi-member owner active in the sibling organization, whose
   membership set the engine threads as `tenantIds` — plus the `single`
   install holding several organizations under a session carrying no active
   organization (nothing to stamp wrongly, but the failed read must end in the
   ambiguity refusal, never a NULL row). Both pinned with their read-working
   controls; `isolated` is unreachable (one organization per session).

2. Review §5 note 3 read the scoped update half's unreachable-row case as a
   SILENT no-op. Measured on the real engine it is loud: `ObjectQL.update`
   reports the unreachable row as RECORD_NOT_FOUND (404), `grant` throws it,
   and `evaluateRule`'s pass aborts there — grants before it stay, the ones
   after it and the stale-row revocation never run. Pinned as measured (the
   direct grant, the two-organizations path with a stale-row witness, the
   re-homed-record path); the implementation is unchanged, and the `grant`
   comment that called it silent now says what was measured.

Census page re-anchored with `--fix` (4 line-rot anchors, 0 refused).

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

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33715540760 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test:  FAIL   integration  test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
      ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1801
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 62 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33716741497 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test:  FAIL   integration  test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
      ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1801
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️ 本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 66 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-sales
os-sales added this pull request to the merge queue Sep 3, 2026
Merged via the queue into main with commit 3f64fe6 Sep 3, 2026
53 of 55 checks passed
@os-sales
os-sales deleted the claude/issue-14484-record-share-org-stamp branch September 3, 2026 08:55
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 size/xl tests tooling

Projects

None yet

3 participants