Skip to content

fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware - #13570

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-13552-rls-empty-membership-polarity
Aug 31, 2026
Merged

os-steve merged 1 commit into
mainfrom
claude/issue-13552-rls-empty-membership-polarity

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #13552

What

isEmptyMembershipFilter in packages/plugins/plugin-security/src/rls-compiler.ts existed so a pre-resolved membership set that resolves EMPTY drops the policy and the single-policy path fails closed via RLS_DENY_FILTER. It shape-matched the bare positive form ({ f: { $in: [] } }) only, while not in is a first-class pushdown shape (!(x in y) lowers to $not wrapping $in). Under $not, an empty $in: [] inverts from constant FALSE to constant TRUE — NOT (1 = 0) on the SQL read-scope lowering — so the policy the guard exists to turn into a DENY compiled to ALLOW-ALL on reads (p1 fail-open, triage grading in issuecomment-5472270131).

The guard is now polarity-aware. It keeps the same convention the platform already uses (drop the policy, deny sentinel upstream — the shape tenant-layer.ts hand-encodes for its Layer 0 empty sets); no second convention is invented.

The enumeration (triage mandatory item 1)

The guard fires — policy dropped, RLS_DENY_FILTER on the single-policy path — for an emptied membership at ODD effective polarity ANYWHERE in the compiled tree, and for the legacy solely-empty positive case:

  1. bare positive { f: { $in: [] } } — pre-existing behaviour, preserved;
  2. direct wrap { $not: { f: { $in: [] } } } — was allow-all, measured 5 of 5 fixture rows;
  3. $not arm nested inside $or — the constant-TRUE arm made the whole $or allow-all (measured 5 of 5);
  4. $not arm nested inside $and — the membership restriction silently evaporated (measured 3 of 5 where deny was intended);
  5. $not over a composite containing the emptied membership ($and case is constant TRUE by De Morgan, measured 5 of 5; $or case reduces to the negation of the other arm — degenerate restriction, fail closed);
  6. multi-level $not, odd depth (triple — measured 5 of 5);
  7. multi-level $not, even depth, solely — constant FALSE; returns the sentinel instead of an always-false filter (same zero rows, one recognisable shape);
  8. multi-key implicit AND under $not — constant TRUE by De Morgan;
  9. defensively, empty $nin: [] (intrinsically constant TRUE — the read-scope SQL lowering renders it 1 = 1). Not emitted by cel-to-filter today; recognised so a future lowering cannot fail open through the same blind spot.

Deliberately NOT firing, matching pre-fix behaviour: a NON-empty membership under $not (the working not in feature, row-level pinned); an emptied POSITIVE membership nested in a composite ($or arm is inert — owner in empty-set || owner == me keeps granting own rows; $and arm is already constant FALSE); literal true (deliberate allow-all, compiles to {}); even-$not emptied membership nested inside a composite (inert constant-FALSE arm).

Before/after control (triage mandatory item 2)

Reverse-verified from the committed state, with the mutation and both restore legs proven on disk:

  • restored rls-compiler.ts to base ff37576 (worktree only), proved the mutation landed (new-guard marker grep-count 0, old-docblock marker count 1), then ran a scratch harness asserting the BUGGY behaviour. It PASSED on base — measured: direct $not compiles {"$not":{"owner":{"$in":[]}}} and admits 5 of 5 rows via matchesFilterCondition; $or-nested 5/5; $and-nested 3/5; composite 5/5; triple-$not 5/5; bare-positive control already denied (0/5, the half that was green).
  • restore leg proven by blob hash: disk blob equals HEAD blob 9360869c63f7554b34afcf015addf84ac35c053e, git status clean; the same harness against the fixed HEAD then FAILS 5 of 6 (every negated pin now gets the deny sentinel; only the unchanged positive control passes). The harness was deleted; the committed suite (rls-empty-membership-polarity.test.ts, 19 tests) asserts the AFTER state including row-level zero-admission for every enumerated shape.

No rebuild was needed for either leg: the suite imports ./rls-compiler.js relative source under vitest transform (no dist resolution for the mutated subject); @objectstack/formula (unmutated) resolves to its freshly built workspace dist.

Blast radius (triage mandatory item 3)

Declared in the changeset (.changeset/rls-empty-membership-polarity-guard.md): callers relying on the allow-all stop seeing rows — if a negated-membership policy was the only applicable policy and its set resolves empty, reads go from every row to zero rows. That prior behaviour was a defect, not a contract. Own-rows access that must survive an emptied set belongs in a separate OR'd policy (per-policy grants compile independently — pinned in the suite); deliberate allow-all remains authorable as literal true.

PM mechanism assumptions, measured

  1. Fix belongs in the guard — in-repo, the analytics lowering's scope input is StrategyContext.getReadScope, wired to security.getReadFilter, i.e. this compiler's output, so post-fix the emptied-negated shape no longer reaches read-scope-sql.ts through the RLS path. The lowering site itself still carries the same polarity-dependent inference ($in: [] folds to FALSE_CLAUSE with a bare "safe" comment; $nin: [] folds to 1 = 1), and the contract type is fillable by non-RLS providers — reported as an out-of-scope finding rather than absorbed (see issue linked from the report).
  2. tenant-layer.ts is the model — confirmed; it returns the spread deny sentinel on empty access sets. This fix reaches the identical sentinel through the existing drop-the-policy channel; no second convention.
  3. No pin asserts the buggy behaviour — confirmed. Existing pins assert positive-polarity deny (security-plugin.test.ts: "should fail-closed for IN when org_user_ids is empty", "should fail-closed when a §7.3.1 membership set is empty"), and the formula-level pin ("empty membership array still compiles to $in:[] (caller decides)") explicitly delegates the decision to this caller. Full plugin-security suite: 91 files / 1684 tests green.

Verification (all at f7347eb)

  • pnpm --filter @objectstack/plugin-security test — 91 files, 1684 passed (includes the new 19).
  • pnpm --filter @objectstack/plugin-security typecheck — green; --listFiles confirms both rls-compiler.ts and the new test file are inside the tsc programs (1 hit each).
  • All 35 gate families derived by scripts/pm/dispatch-gates.mjs from this diff: 33 green (incl. check:engine-double-contract, check:where-matcher, check:type-check-debt re-measure with zero surplus, check:i18n, check:cross-package-test-inputs); 2 NOT MEASURED by design per their own exit-3 text (check-test-completeness grades a saved CI turbo log; check-half-states needs a GitHub credential this container lacks). check:nul-bytes green. Repo-wide pnpm lint (eslint, no-inline-config) exit 0.
  • Census disjointness re-confirmed on this tree: zero isSystem reads in rls-compiler.ts, zero census anchors on it.

The isEmptyMembershipFilter export is for direct shape tests only; index.ts deliberately does not re-export it, so the package surface is unchanged.

Generated by Claude Code


Generated by Claude Code

…rity-aware

An emptied pre-resolved membership set under a supported `not in`
(`$not` wrapping `$in: []`) inverted to a constant-TRUE clause and
compiled to allow-all on the read scope instead of the deny sentinel.
The guard now fires on odd-polarity emptied memberships anywhere in the
compiled filter tree (direct `$not`, `$not` arms inside `$or`/`$and`,
`$not` over composites, multi-level `$not`, multi-key implicit AND) and
keeps the legacy positive single-policy case, generalised through double
negation. Empty `$nin` (intrinsically constant TRUE) is recognised
defensively. Non-empty `not in` and inert positive composites are
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 — 14 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 b9972720f843033d24ec657f3d17b75435fca74a → packageMentionDocs.

Which tree this was computed on

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

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

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

@os-steve
os-steve marked this pull request as ready for review August 31, 2026 02:37
@os-steve
os-steve enabled auto-merge August 31, 2026 02:37
@os-steve
os-steve added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit 09b0d7b Aug 31, 2026
34 checks passed
@os-steve
os-steve deleted the claude/issue-13552-rls-empty-membership-polarity branch August 31, 2026 02:55
os-steve pushed a commit that referenced this pull request Aug 31, 2026
…ng instead of folding it to constant TRUE (#13571)

An emptied exclusion folded to '1 = 1' — constant TRUE — which vacates the
whole read scope: every row admitted, no $not needed, on the lowering where
a wrong answer is ADR-0021 scope over-reach. It now throws in the module's
one refusal envelope (READ_SCOPE_COMPILE_FAILED / 500), like the arity check
one line above.

Deliberately asymmetric (domain:services ruling, 2026-08-31): $in: [] keeps
its ruled #5322/#5243 constant-FALSE fold. That fold is narrowing at its own
arm and load-bearing — the RLS compiler deliberately emits an emptied
positive membership inside composites (PR #13570's 'own rows keep flowing'
pin), and that filter reaches this compiler through security.getReadFilter.
A uniform throw was measured and rejected: it would 500 every analytics
query for any user whose membership set resolves empty beside an own-rows
grant. $nin: [] has zero producers (the CEL lowering never emits $nin; the
#13570 guard drops even-polarity empty-$nin policies), so this refusal
costs no live traffic.

Includes a non-RLS getReadScope provider control (the spec contract filled
by hand) pinning refusal post-fix, and the over-denial control pinning that
the #13570 composite still compiles and still admits exactly the own row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…ng instead of folding it to constant TRUE (objectstack-ai#13571) (objectstack-ai#13649)

An emptied exclusion folded to '1 = 1' — constant TRUE — which vacates the
whole read scope: every row admitted, no $not needed, on the lowering where
a wrong answer is ADR-0021 scope over-reach. It now throws in the module's
one refusal envelope (READ_SCOPE_COMPILE_FAILED / 500), like the arity check
one line above.

Deliberately asymmetric (domain:services ruling, 2026-08-31): $in: [] keeps
its ruled objectstack-ai#5322/objectstack-ai#5243 constant-FALSE fold. That fold is narrowing at its own
arm and load-bearing — the RLS compiler deliberately emits an emptied
positive membership inside composites (PR objectstack-ai#13570's 'own rows keep flowing'
pin), and that filter reaches this compiler through security.getReadFilter.
A uniform throw was measured and rejected: it would 500 every analytics
query for any user whose membership set resolves empty beside an own-rows
grant. $nin: [] has zero producers (the CEL lowering never emits $nin; the
objectstack-ai#13570 guard drops even-polarity empty-$nin policies), so this refusal
costs no live traffic.

Includes a non-RLS getReadScope provider control (the spec contract filled
by hand) pinning refusal post-fix, and the over-denial control pinning that
the objectstack-ai#13570 composite still compiles and still admits exactly the own row.


Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…commits that decided them (objectstack-ai#20729)

Part of objectstack-ai#20596
Clause-②: no

## What changed

This is the eighth stage of the `domain:services` lane of the
dead-citation sweep. It covers
`packages/services/service-analytics/src/**` and nothing else. By the
seat's census at the claim (`5899485578`), it is the largest package in
the lane that no in-flight work holds. Later stages cover the other
packages, so this PR says `Part of` and the card stays open.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123), by the method of stages 1 to 7 (PR objectstack-ai#20609 as
`422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR
objectstack-ai#20658 as `9a4b2bb38`, PR objectstack-ai#20693 as `0e9ad74fb`, PR objectstack-ai#20708 as
`9b384f63a`, PR objectstack-ai#20717 as `cbaf04c1f`). That is **76 sites on 76 lines
in 22 files, covering 14 numbers**:

- 42 census sites (every census site this package has);
- 34 sites in test comments, which the census defers.

The raw scan found no dead site the gate's grammar cannot see (see
Acceptance notes), so there is no third class this time.

Each rewritten line now cites the commit in `origin/main` history that
decided what the line describes, and says in its own words what was
decided: **13 distinct shas**. No number in this package has an ADR or
ruling record of its own in the repository (a grep of `docs/adr/` for
all 14 finds none, and a grep of the rest of `docs/` finds none either),
so every anchor is a commit, per ruling C's order. No number was
dropped.

Only comments changed. Every touched source file keeps its line count
(78 lines out, 78 in, over 22 files), so no line citation into these
files moves. 2 of those 78 lines hold no dead citation: they are reflow
lines, listed under Wordings below. No code token moves (see the guard
below).

**No citation number is added.** Every tracker number on an added line
was already on the line it replaces: `objectstack-ai#10861` (5 lines), `objectstack-ai#12776` (3),
`objectstack-ai#10413` (2), `objectstack-ai#16750` (2), and `objectstack-ai#10759`, `objectstack-ai#11152`, `objectstack-ai#5716` and the
decision-batch ordinal `objectstack-ai#59` once each. Each tracker number among them
resolves. Over the whole diff, added minus removed is 0 or negative for
every number, and no number is new to the diff. No PR number is the
citation on an added line: the two `PR #N` spellings in scope became
their pull request's squash commit, and `objectstack-ai#16750` stays only as the
convenience link beside `ed7243d52`, on the line it already stood on.

Eight dead sites are left on purpose, all of them test strings (see the
list below).

One more file: a `patch` changeset for `@objectstack/service-analytics`,
because the rewritten docblocks and inline comments ship (see Changeset
below).

The `AnalyticsResultWithDrill` type and its four sidecar members are not
touched: its docblocks carry no dead number (`objectstack-ai#20644`, `objectstack-ai#3214` and
`objectstack-ai#1752` all resolve).

## Census: `service-analytics`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count below is its `allocated-but-absent` findings under
`packages/services/service-analytics/`. Each run counts as a reading
only because its board frontier equals the newest issue number, read by
a separate request just before and just after the run.

| reading | tree | board | whole-repo `allocated-but-absent` |
service-analytics sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `cbaf04c1f`, run 2026-09-29T21:41:53Z to 21:45:05Z |
enumerated, 186 pages, frontier objectstack-ai#20721 (newest objectstack-ai#20721 before and after),
18,548 numbers | 1,161 | **42** | 42 | 10 | 10 |
| after | head `967d73531`, run 21:55:23Z to 21:58:36Z | enumerated, 186
pages, frontier objectstack-ai#20723 (newest objectstack-ai#20723 before and after), 18,550 numbers
| 1,119 | **0** | 0 | 0 | 0 |

The before count matches the seat's census at the claim and A1 (42
sites): the two comments PR objectstack-ai#20712 rewrote in `analytics-service.ts` did
not move it. The whole-repo drop is 42, exactly this diff's census
sites. The `resolves` tally is 32,991 in both runs, and
`resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995) did
not move either. The after run was taken on `967d73531`; the head
`82d2b40b2` adds only the changeset. No run was truncated or discarded:
both enumerations read 186 pages at the newest frontier.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `namesThisRepository`
over every `.ts` file under `service-analytics/src` (162 files). It
takes its verdicts from the before census's own board reading rather
than from a second enumeration: a number is dead when that census
reported it `allocated-but-absent`, and alive when that census judged it
on this board anywhere (its `--list` extraction) and did not report it.
The 21 numbers the census never saw, because they stand only in test
files or strings here, were read one by one on the issues endpoint: 17
answer 200, and `objectstack-ai#16778`, `objectstack-ai#16860`, `objectstack-ai#16918` and `objectstack-ai#17125` answer 404.

| reading | citations | dead | src comment | test comment | src string |
test string |
|---|---|---|---|---|---|---|
| before, `cbaf04c1f` | 3,514 | **84** | 42 | 34 | 0 | 8 |
| after, `967d73531` | 3,438 | **8** | 0 | 0 | 0 | 8 |

Its src-comment column equals the census's 42, which is the control on
the second instrument. The 3,410 live citations and the 20 cross-repo
citations are the same in both readings, and the drop of 76 citations is
exactly the rewritten sites. A third, raw reading (every `#` followed by
2 to 6 digits, whatever surrounds it) finds 3,598 occurrences and 84
dead before, 3,522 and 8 after; its residue equals the gate's residue
site for site, and it sees no dead site beyond the gate.

## Per-number table

Sites and files count every dead occurrence in scope at the base
(comments and strings, tests included). `rewritten / left` counts the
sites rewritten and the sites left. Each anchor was read in its message
and diff, not only its subject, and `git blame` at the base puts every
rewritten line in its anchor commit or in a later commit that descends
from it (`merge-base --is-ancestor` exit 0 for each pair).

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#11461` | 20/2 | 19/1 | `399ecad58`: a cross-object leaf in one
measure's own `filter` (the third producer, lowered onto
`aggregations[].filter`) is refused on both ObjectQL doors with
`INVALID_FIELD` / 400 naming the measure, folded into the one member
view, with insertion order keeping every earlier refusal's message. The
last line of its message names `objectstack-ai#11461` as the card it settles. New to
the sweep |
| `objectstack-ai#17130` | 17/5 | 13/4 | `54b3d1d4a` (PR objectstack-ai#17336): the row-scope
resolution refusals carry `READ_SCOPE_COMPILE_FAILED` / 500 through one
constructor, so `queryDataset`'s catch re-throws them instead of reading
their words, every message byte-unchanged; plus the source-derived
wording-collision guard. Named in its diff only (18 added lines carry
the tag). New to the sweep |
| `objectstack-ai#17124` | 12/8 | 10/2 | `86c505286` (PR objectstack-ai#17593):
`explicitDateRangeWindow` is the one reading of `dateRange`'s array arm
on all four faces, and an array that is not two string bounds is refused
with `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400. Named in its diff only
(its changeset file is `17124-daterange-array-arm-arity.md`). New to the
sweep |
| `objectstack-ai#12209` | 10/5 | 10/0 | `017130a09` (PR objectstack-ai#12318): a custom-SQL measure
is refused on the ObjectQL aggregate path with `INVALID_FIELD` / 400,
keyed on the `EXPRESSION_METRIC_TYPES` partition shared with
`NativeSQLStrategy`. Its message records the two failure modes the lines
describe (`driver-sql` blaming a `function` key, the in-memory evaluator
answering `null` per bucket). Named in its diff only. New to the sweep |
| `objectstack-ai#16778` | 5/1 | 4/1 | `357f4992b`: the compile-leg refusal of an
aggregate a datetime measure's field type cannot carry, scoped to
temporal source fields. The squash commit of the pull request that was
`objectstack-ai#16778`; its subject carries the number. New to the sweep |
| `objectstack-ai#12940` | 4/2 | 4/0 | `aa16721b6` (PR objectstack-ai#13361): this package's
consumer-local `executeAggregate` config mirrors (the plugin options and
`AnalyticsServiceConfig`) narrow `aggregations[].method` to
`AggregationFunction`, after `objectstack-ai#12776` narrowed the contract. Named in
its diff only. New to the sweep |
| `objectstack-ai#17015` | 4/2 | 4/0 | `0da638cd9`: the closed `dateRange` preset
vocabulary is lowered once and the rest refused, the `[range, range]`
fallback is removed from the faces it reached, and the shared
conformance kit holds them. The squash commit of the pull request that
was `objectstack-ai#17015`. New to the sweep |
| `objectstack-ai#16860` | 3/1 | 3/0 | `041d9fdc6`: the object-level read grant is
asked at the analytics door, and its bridge to the `security` service
resolves an explicit three-way (absent admits; throwing or method-less
denies at `error`, finding F3 in its message). The squash commit of the
pull request that was `objectstack-ai#16860`. New to the sweep |
| `objectstack-ai#12248` | 2/1 | 2/0 | `8425c17cc`: the five ruled engine members,
`getDriverForObject?` and `resolveEffectiveDatasource` among them,
adopted onto `IDataEngine`, and `getObject` typed. Its subject names it.
Stage 5's and the spec stage's anchor |
| `objectstack-ai#16685` | 2/2 | 2/0 | `ed7243d52` (PR objectstack-ai#16750): `boolean` / `toggle`
accepted for `sum` / `avg` / `min` / `max` in the aggregate × field-type
table, holding maintainer ruling `objectstack-ai#11152`. Its subject names it. The
spec stage's anchor |
| `objectstack-ai#17125` | 2/2 | 2/0 | `5d12b16e7`: the row-scope bridge tells an
absent security service from a broken one, so a broken one refuses the
query. The squash commit of the pull request that was `objectstack-ai#17125` (404 on
the pulls endpoint too). New to the sweep |
| `objectstack-ai#16918` | 1/1 | 1/0 | `5d12b16e7`: the same commit. Its changeset's
headline names `objectstack-ai#16918` as the card it answers, and its diff writes the
line (`admission-bridge-resolution.test.ts:120`) |
| `objectstack-ai#6123` | 1/1 | 1/0 | `59d1933f9`: `err.code` lands at `error.code`,
not `error.details.code`; the commit that wrote this very line. The
`runtime` stage's anchor |
| `objectstack-ai#13279` | 1/1 | 1/0 | `6a180e42d`: permission-store read failures
fail loud, and the same commit renames
`metadata/src/utils/schema-sync-errors.ts` to
`packages/types/src/driver-error-classification.ts`, the move the line
describes. The anchor of stages 2, 5 and 6, and of the `types`, `rest`
and `runtime` stages |

Every cited sha matches exactly one commit (`git rev-parse
--disambiguate`, count 1 for each of the 13), and every one is an
ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 13;
control leg: stage 1's landing `422db788a` exit 0; the history is
complete, `--is-shallow-repository` false, 15,135 commits). Each of the
14 numbers answers 404 on the issues endpoint, read one by one;
`objectstack-ai#16778`, `objectstack-ai#16860`, `objectstack-ai#17015` and `objectstack-ai#17125` answer 404 on the pulls
endpoint too.

## Wordings to check

- **Bracket tags.** `[#N]` became `[commit SHA]`, as in stage 7;
`[objectstack-ai#10861 / objectstack-ai#11461]` and `[objectstack-ai#10861, objectstack-ai#11461]` keep the live `objectstack-ai#10861` beside
the new sha.
- **The boolean rows, `measure-result-type.ts:115-116` and
`aggregate-datetime-measure-refusal.test.ts:65-66`.** 「objectstack-ai#16685 ruled A,
landed as objectstack-ai#16750」 and 「objectstack-ai#16685 was ruled A and objectstack-ai#16750 added」 became
「commit ed7243d (objectstack-ai#16750) added those rows」 and 「commit ed7243d
(objectstack-ai#16750) added」. 「ruled A」 named an option on the dead card;
`ed7243d52`'s message records the decision itself. Line 116 of the first
file and line 66 of the second are the 2 reflow lines: each keeps the
`objectstack-ai#16750` it already carried.
- **PR numbers, `read-scope-resolution-envelope.test.ts:25` and
`refusal-wording-collision.test.ts:21`.** 「PR objectstack-ai#17125's refusal」 became
「Commit 5d12b16's refusal」, the pull request's squash commit.
- **`read-scope-refusal.ts:29`.** 「objectstack-ai#17130 exists to remove it」 became
「commit 54b3d1d was made to remove it」, the form stage 6 used.
- **`refusal-wording-collision.test.ts:49`.** 「the exact move objectstack-ai#17130
forbids」 became 「the exact move commit 54b3d1d ruled out」; its message
says the fix is the declaration, not a luckier string.
- **`read-scope-resolution-envelope.test.ts:161`.** The verb after the
number moved from present to past tense with the sha.
- **`measure-expression-both-strategies.test.ts:45` and `:166`.**
「deleting the objectstack-ai#12209 arm in」 became 「deleting the arm commit 017130a
added in」, and 「every objectstack-ai#12209 refusal」 became 「every custom-SQL refusal
(commit 017130a)」.
- **`dataset-executor.ts:609`.** 「objectstack-ai#17015's kit」 became 「commit
0da638c's kit」, the conformance kit that commit built.
- **`plugin.ts:116`.** 「and in objectstack-ai#12209:」 became 「and in commit
017130a:」, whose message records the two ways the engine failed.
- **`analytics-service.ts:238`.** 「objectstack-ai#13279 moved it there」 became 「commit
6a180e4 moved it there」; that commit's diff is the rename.

## The 8 sites left

- **Test strings, 8 sites**, left as stages 1 to 7 left theirs, all
`describe` / `it` titles:
  - `crossobject-conjunct-refusal.test.ts:589` (`objectstack-ai#11461`);
  - `aggregate-nontemporal-measure-refusal.test.ts:243` (`objectstack-ai#16778`);
  - `date-range-array-arm-arity.test.ts:213` and `:294` (`objectstack-ai#17124`);
- `read-scope-resolution-envelope.test.ts:155`, `:199` and `:226`, and
`refusal-wording-collision.test.ts:336` (`objectstack-ai#17130`).
- There is no operator string, generated file or quoted ruling carrying
a dead number in this package. It has no generated file at all.

## Mechanical guard: no code token moves

The guard compares the TypeScript parser's leaf nodes, with comments as
trivia and JSDoc nodes never visited, base `cbaf04c1f` against head.
Template literals are therefore read in context. It ran over all 22
touched `.ts` files.

- Real run: 26,705 base leaf tokens, **0 files with a token change**
(exit 0).
- Comment control in `plugin.ts` (「refusal buys is in」 to 「refusal earns
is in」): 0 files changed, as expected (exit 0).
- Positive control, a code token added in `plugin.ts` (`field: a.field,`
given `as string`): DIFFER (exit 1).
- Positive control, one digit changed inside a kept test title
(`date-range-array-arm-arity.test.ts:213`): DIFFER (exit 1).

Every mutation went through `scripts/ablation-replace.mjs`, and each
landed (anchor 1 to 0, blob changed). Each restore was proven
byte-identical to the HEAD blob (`ad3dc9fff4d3`, `a606ffbb6ead`), with
`git diff HEAD` empty and a clean tree afterwards.

## Changeset

This change ships bytes, so a `patch` changeset for
`@objectstack/service-analytics`
(`.changeset/20596-service-analytics-provenance-anchors.md`) is
included. Its body is stage 7's, word for word, with the package name
changed.

Measured on the built package (A3): `files[]` is `dist`, `README.md` and
`CHANGELOG.md`. After the build (a cache miss for this package, so
`dist` is this head's source), the rewritten comments reach `dist`:
`399ecad58` 6 times in each of `dist/index.js`, `index.cjs`,
`index.d.ts` and `index.d.cts`; `86c505286` twice in each JS file and
once in each declaration file; `54b3d1d4a` once in all four; `aa16721b6`
once in each JS file and twice in each declaration file; `017130a09`
once in each JS file. Positive controls: the unchanged line 「none of the
coverage: a compiled measure's own」, in the same docblock as the shipped
rewrite at `objectql-strategy.ts:744`, is found once in each of the four
files, and the unchanged line 「back into line. Widening it here again
would not be a local matter」 beside the shipped rewrite at
`analytics-service.ts:559` once in each declaration file. A
never-written negative phrase appears nowhere in `dist`. None of the 14
dead numbers is left anywhere in `dist`.

## Gates (head `82d2b40b2`)

- **Citation judging, as CI runs it:** `pnpm check:issue-citations`
(self-test) exits 0. `node scripts/check-issue-citations.mjs` exits 0:
the diff-scoped run judged 11 citations across 10 files; 10 resolve and
1 resolves as a pull request (`objectstack-ai#16750`, the convenience link that
already stood on its line).
- **Doc authoring:** `pnpm check:doc-authoring` exits 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `82d2b40b2` derived 62 commands:
all 56 derived at dispatch, plus `check:engine-double-contract`,
`check:objectql-double-limit`, `check:query-options-erasure`,
`check:type-check-coverage`, `check:type-check-debt` and
`check:where-matcher`. Each ran with its exit code captured before any
pipe, and all 62 exit 0. `--ran`, fed each command with its exit code,
reports 62 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. A
full `turbo run build` of `./packages/*` and `./packages/*/*` ran first
under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an
unbuilt workspace.
- **Roster families the derivation lists outside its commands** (their
rosters sit in directories this diff touches): `node
scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm
check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit
0.
- **Tests and typecheck, under the verify lock:**
- `pnpm --filter @objectstack/service-analytics test`: 137 files pass
and 3,216 tests pass. That is every test file in the package, the 12
touched ones included.
- `pnpm --filter @objectstack/service-analytics typecheck` exits 0 (`tsc
--noEmit` on `tsconfig.json`). `--listFiles`: the program holds all 162
files under `src/`, the 137 test files and all 22 touched files
included.
- **Lint, as a proven narrowing:** `eslint --no-inline-config --format
json` over the 22 touched `.ts` files gives 22 files, 0 errors and 0
warnings. All 22 are in eslint's own population (`isPathIgnored` is
false for each; a `dist` file, as the control, is ignored).
`eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`, as its own lines 327-328 state), so a comment
edit here cannot move the verdict on any untouched file. The repo-wide
`pnpm lint` is CI's run.
- **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of
the 23 changed files for control bytes finds none.

## Acceptance notes

- **The gate-invisible spellings, grepped as the claim asked.**
`CITATION_RE` refuses a hyphen after the digits and a `/` before the `#`
(objectstack-ai#20636), and `NON_CITATION_HEADS` excuses a number after the word
「option」. In this package:
- `#N-word`: 8 lines by a plain grep, and 7 once a hyphen before the `#`
is excluded too, which is the claim's 7. The eighth is
「pre-objectstack-ai#10413-phase-2」 (`execution-context-bridge.test.ts:223`). The
numbers, `objectstack-ai#10413`, `objectstack-ai#5298`, `objectstack-ai#13570` and `objectstack-ai#13640`, all resolve.
- `#A/#B`: 29 lines, the claim's 29, over 28 distinct numbers. All
resolve; `objectstack-ai#2149`, which the census never judged, was read on its own.
  - `option #N`: none.
So nothing here needed a rewrite beyond the gate, and the raw scan
agrees.
- **「This card」 phrases are left.** 113 lines in 39 files of this
package speak of 「this card」, 「that card」 or 「the card」. They carry no
number, neither instrument sees them, and most sit in blocks whose
numbers still resolve. Stage 7 rewrote two such lines as lost referents;
here none is changed, because the phrase runs through the whole package
and rewriting a subset would be arbitrary.
- **Prose that names `queryDataset`'s catch, not changed.** Nine comment
lines say `queryDataset`'s catch. Since `10c36cc43` that catch sits in
the private `answerDataset`, whose docblock calls it the body of
`queryDataset`, so the lines still hold at the level of the public
method. This is not a dead citation, so it is outside this stage.
- **The census instrument did not truncate in this stage.** Both
enumerations read 186 pages at the newest frontier.
- **Anchors the next stages can reuse**, each checked here: `objectstack-ai#11461` →
`399ecad58`; `objectstack-ai#17130` → `54b3d1d4a`; `objectstack-ai#17124` → `86c505286`; `objectstack-ai#12209` →
`017130a09`; `objectstack-ai#16778` → `357f4992b`; `objectstack-ai#12940` → `aa16721b6`; `objectstack-ai#17015` →
`0da638cd9`; `objectstack-ai#16860` → `041d9fdc6`; `objectstack-ai#17125` and `objectstack-ai#16918` →
`5d12b16e7`.
- **Base.** The branch is on `main` at `cbaf04c1f`. `main` has since
moved four commits (`3711e0b76`, `61455de27`, `6afccda5a`, `671d4c164`).
They touch `packages/spec`, `packages/metadata/package.json`,
`pnpm-lock.yaml`, docs and changesets, and no file under
`service-analytics` or in this diff, so no merge was taken; the merge
queue rebuilds on the merged generation. One of them, `671d4c164`,
declares the four drill-through sidecars on `AnalyticsResult` in the
spec. This diff leaves the local `AnalyticsResultWithDrill` untouched,
as the claim requires.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants