chore: T01.o est close, la fuite du filtre de lecture est fermee et epinglee

This commit is contained in:
Sylvain Duchesne
2026-09-24 18:44:22 +02:00
parent 71d94b2816
commit 0e2b8ba394
2 changed files with 163 additions and 57 deletions
@@ -0,0 +1,163 @@
---
id: T01.o
title: The read filter is bypassed by plain properties, and no fake shows it
parent: T01
depends_on: []
created: 2026-09-24
claimed_by: worker-T01.o
claimed_at: 2026-09-24
blocked: false
---
## Description
**A rights leak on the published surface.** The capability filter around the published `useShape` is bypassed by the SDK version this repo actually builds against, and every gate stays green because no fake anywhere returns the shape the SDK ships. Found by T01.g, the closing review.
### The leak - VERIFIED from primary sources on both sides, do not re-derive
`packages/polyfill/src/emulated-verifier/read-filter.ts` does `const v = Reflect.get(target, prop, target); if (typeof v !== "function") return v;` - a non-function member is returned RAW, on the recorded ground that "a plain property carries no items".
That ground is true of the CITED tree (alpha.18, where `useShape` returns a bare `DeepSignalSet`) and FALSE of the INSTALLED `@ng-org/orm@0.1.2-alpha.21`. Its shipped `dist/frontendAdapters/react/useShape.d.ts` returns `UseShapeResult`, whose members include three PLAIN PROPERTIES that each carry the items: `data: SUBSCRIPTION_DATA | undefined`, `promise: Promise<SUBSCRIPTION_DATA>`, and `subscription: RdfOrmSubscriptionFor<...>`. Its own doc comment states that `data` IS the value returned by `RdfOrmSubscription.signalObject`.
Measured by T01.g with bob as holder and `isEnforcing() === true`: the alpha.18 shape iterates to `["bob-own"]`, while alpha.21's `.data`, `.subscription.signalObject` and `await .promise` each yield `["bob-own", "alice-private"]`, and nothing throws. That falsifies `contract_polyfill-surface.md:256` ("anything else throws").
### THE ASYMMETRY IS THE FAULT, and the code already argues against itself
For an unknown FUNCTION member the same file refuses rather than forwards, with the reason written beside it: forwarding is a silent leak, and this view's one job is that it cannot show more than the holder may read. That reasoning was never applied to non-function members. Naming the asymmetry is not the same as prescribing the repair - weigh what the right policy is, including what refusing `data` would cost a legitimate caller, and say what you rejected.
**Do NOT fix this by filtering three member names.** `data`, `promise` and `subscription` are alpha.21's shape; a name list is what breaks silently at alpha.22, and breaking silently on an SDK bump is exactly how this defect arrived. Whatever you build must be safe for a shape nobody has seen yet.
**The line that must hold:** a legitimate caller still gets its own data, through whichever member the SDK offers. A repair that makes the published `useShape` unusable has traded a leak for a break.
### Why it was invisible, which must be fixed in the same task
`packages/polyfill/e2e/orm-live-entry.ts:101-109` - the only live gate for the published `useShape` - injects a HAND-WRITTEN `sub.signalObject` substitute. So no fake in this repository returns the shape the SDK actually ships, and the leak cannot fail a test. Fixing the filter without fixing this leaves the next SDK shape change just as invisible.
Build a fake that returns the SHIPPED shape - read it from `node_modules/@ng-org/orm/dist/frontendAdapters/react/useShape.d.ts`, which is the artifact that settles it - and make it the one the gates use. A test that would not have caught this defect is not coverage.
### Two documentation faults that ride with it
- `contract_polyfill-surface.md` instructs injecting "the REAL @ng-org/orm useShape" (see `docs/api-contract.md:50`), but `@ng-org/orm`'s `.` export maps to `dist/core.d.ts`, which exports NO `useShape` - it lives at `/react`, or as `reactUseShape` from `/all`. The instruction does not resolve. CONFIRMED by T01.g.
- Four present-tense claims that the SDK's `useShape` returns a bare `DeepSignalSet` are false of alpha.21, and TWO are load-bearing provenance grounds: `use-shape-query.ts:284`'s `announced-not-written` premise, and `:193`'s `ShapeQuery kind=invention level=none ref=none`, justified as "no upstream type carries load state" when `UseShapeResult.isLoading` does. This is the `readUnion` failure mode again - a citation that resolves, attached to a claim that is false - and `provenance.test.ts` cannot see it by construction.
### Verification
**The leak's own test comes first and must fail on baseline.** Bob holding no capability for alice's document must not reach her items through ANY member of the returned object. Name the test.
**Hard gate:** `bun test` -> 0 fail, baseline `465 pass / 0 fail` which the orchestrator measured; `pnpm exec tsc --noEmit -p packages/polyfill/tsconfig.json` -> exit 0; the e2e tsconfig -> exit 0; `python3 ~/.claude/skills/concept/lint.py` -> 0 ERROR; and the polyfill e2e run and reported verbatim, since the injected fake changes. Broker login flakes roughly 3 runs in 10 before library code runs - re-run a flaked login.
Anti-cheat: no `any`, no `@ts-ignore`, no `as unknown as`, no deleted or skipped test, no weakened assertion. And specific to this one: no widening `UseShapeLike` to make a shape typecheck - `src/model/types.ts:83` is already `(...args:any[]) => any`, which is why both typechecks stayed at 0 while the leak was open, and that is a finding in itself worth addressing.
**A guarantee and the test that pins it land in the same commit.** The contract's claim at `:256` is currently false; it becomes true or it changes, in the same commit as its test.
**An independent adversarial review runs on this afterwards.** Rights defects get one whatever the gate says.
## Progress
### The enumeration, done before the code
Read from the artifact that settles it: the INSTALLED `@ng-org/orm@0.1.2-alpha.21`, at
`node_modules/.pnpm/@ng-org+orm@0.1.2-alpha.21/node_modules/@ng-org/orm/dist/frontendAdapters/react/useShape.{d.ts,js}`.
The `.d.ts` declares `UseShapeResult_` with SIX members, wrapped by `WithMaybePagination`, which makes `nextPage` and
`previousPage` optional when the config asks for no `pageSize`. The shipped `.js` returns exactly those six keys. Both
were read; they agree.
| member | declared type | does it carry items? | what the filtered view must answer |
|---|---|---|---|
| `data` | `SUBSCRIPTION_DATA \| undefined` - a `DeepSignalSet<T>` with no `orderBy`, a `DeepSignal<ReadOnlyArray<T>>` with one | **YES, directly.** Its own doc comment: "This object is the value returned by `RdfOrmSubscription.signalObject`" | a read-filtered VIEW of that collection. `undefined` is a primitive and passes untouched |
| `promise` | `CONF extends undefined ? undefined : Promise<SUBSCRIPTION_DATA>` | **YES, on resolve** - the same collection | a promise that resolves to the read-filtered view of what it resolved to |
| `subscription` | `CONF extends undefined ? undefined : RdfOrmSubscriptionFor<ST, CONF, T>` | **YES, three ways**: `signalObject`, `readyPromise`, and `addChangeListener`'s `{adds, updates, removes}` of raw items | **REFUSE, loudly.** This surface already says `RdfOrmSubscription` is not published and that reaching it "defeats everything above"; handing it back through `useShape` publishes it by another door |
| `isLoading` | `boolean` | no | pass through - a primitive cannot reach an item |
| `nextPage` | `(() => void) \| undefined` (optional without `pageSize`) | no - returns `void`, it loads more rows into the RAW collection, which is only ever seen filtered | forward, bound to the target, like `add`/`delete`/`clear` |
| `previousPage` | `(() => void) \| undefined` (optional without `maxActivePages`) | no - same | forward, bound to the target |
| **anything a later version adds** | unseen | unknowable | decided by TYPE, not by name: a primitive passes, a function comes back as a function that throws when called, an object throws on read |
Two members of the COLLECTION had to be enumerated with it, because `data` is not always a Set:
| member | carries items? | answer |
|---|---|---|
| `length` (array-like `data`, i.e. an `orderBy` config) | a COUNT of them - showing the unfiltered count shows MORE than the holder may read | filtered, beside `size`, and each only when the target itself has it |
| `"0"`, `"1"`, ... (array-like `data`) | **YES** - a raw item | refused by the type rule (an item is an object). Left refused rather than projected: safe, loud, and making the ordered form ergonomic is a wider job than this task |
### The two-layer shape that falls out
The published `useShape` was wrapping an ENVELOPE in a filter written for a COLLECTION. So the envelope's own members
were read through `Reflect.get` and, being non-functions, returned raw. Under alpha.21 that view is ALSO unusable for
its owner - `[...view]` and `.size` throw, because an envelope is not iterable. There are two objects to filter, so
there are two views, and `surface/use-shape.ts` applies the envelope one.
## Result
Status: done. Four commits on `caps-p1a-and-virtual-user-boundary`, `3a89411` `e16fd6a` `e86e249` `b1b099b`. Whole change: **20 files, 1117 insertions, 247 deletions** (`git diff 5993a8c --shortstat`). The published surface is `1.0.0-dev.11`, a major, with the change-policy entry written beside the guarantee it changes.
### The enumeration's conclusion
Written into `## Progress` BEFORE the code, off the installed package's `.d.ts` and the `.js` beside it (they agree). Six members, and the answer differs by what each carries, not by what it is called. `data`, `promise`, `subscription` carry the items; `isLoading`, `nextPage`, `previousPage` do not. Two members of the COLLECTION had to be enumerated with them, because `data` is a `DeepSignalSet` only when no `orderBy` is asked for.
### The policy, and what was rejected
**Decide by the TYPE of the value, not by the name of the member.** A primitive holds no reference, so it cannot reach an item and passes; an object or a function can, so it refuses unless this file has a filtered form of it. Nothing in the code names `isLoading` - it passes because it is a boolean, which is the proof the rule is name-independent.
Rejected, with the deciding reason each time:
- **Filtering the three names.** Ruled out by the task and correct to rule out: it is a list that goes stale on the next bump, which is exactly how this defect arrived.
- **Keeping ONE view and teaching it the envelope.** A `UseShapeResult` and a `DeepSignalSet` have nothing in common - the collection view threw on `[...result]` and `.size` because an envelope is not iterable. One object cannot be both, so there are two views and `surface/use-shape.ts` applies the envelope one.
- **Wrapping `subscription` rather than refusing it.** It reaches the items three ways (`signalObject`, `readyPromise`, `addChangeListener`'s raw arrays) and this surface already says `RdfOrmSubscription` is not published, because one wallet hosts every identity. Wrapping it would publish it by another door and make a second surface to keep honest.
- **Returning `undefined` for a refused member instead of throwing.** Silent is what this file exists not to be.
### Safe for a shape nobody has seen
A member a later `@ng-org/orm` adds is already decided: if it carries items it is an object or a function, and both refuse. `test/use-shape.test.ts` pins it with a hook returning `alpha.21`'s six members PLUS three nobody has declared - `rows` (the collection), `sample()` (a function yielding an item) and `totalCount` (a number). The first throws at the read, the second at the call, the third passes.
### Both directions proved
**The stranger blocked** - `no member of the result reaches a document this identity holds no cap for`: bob reads `data`, `data.size`, `await promise` and gets only his own, `subscription` throws, and the raw collection is asserted to hold BOTH so it is the filter that removed one. Plus `the item-yielding members of data are filtered too`. **5 of the 7 first-written tests FAIL on baseline** (measured by stashing `src/` alone), including both rights tests.
**The owner served** - `the holder reads their own items through every member the SDK offers`: alice reads `data`, `data.size`, `await promise`, `isLoading`, and both page commands forward. And against the DEPLOYED broker: `e2e/orm-live-read.ts`, `5 passed, 0 failed`, the live read ready in 11ms through `data` and `.delete()` on it removing the triples from the document.
### The new fake
`packages/polyfill/test/use-shape-fake.ts`, shared by `bun test` and BOTH e2e entries, returns the shipped envelope: `data` a `Set` or an ordered ARRAY per `SubscriptionData<T, CONF>`, `promise` resolving to it, `subscription` carrying the items three ways, `isLoading`, and the page commands present only under a paginated config. `e2e/orm-live-entry.ts` builds the same object out of the real `getOrCreate`, expression for expression with the shipped adapter minus React's two pieces, and calls the SDK's own `normalizeConf` rather than restating it.
### `UseShapeLike`
Narrowed to `<T extends BaseType>(shapeType: ShapeType<T>, conf: RdfOrmConfig<T> | string | undefined) => ShapeResult`, with `ShapeResult` restated in `model/types.ts` because `UseShapeResult` is exported from NO subpath of the package (`.` is `core`, `/react` the hook alone, `/all` the config types alone - VERIFIED). A compile-time assertion in `test/use-shape.test.ts` pins that the SDK's own `reactUseShape` is assignable with no cast. Stated plainly rather than overclaimed: **this does not by itself prevent a leak** - the defect was runtime proxy behaviour - what it removes is the `any` that let every piece of prose in the repo say the hook returns a bare `DeepSignalSet` with nothing able to contradict it.
### The contract at `:256`
**True now**, and rewritten to say what is filtered, what is refused, and how an unseen member is decided. `1.0.0-dev.11` in `packages/polyfill/package.json`, and the guarantee and its test are in the same commit (`3a89411`).
### The adversary re-run on the fix, which found three more holes
Per `adversarial-findings-need-verification`, measured rather than reasoned. `Object.getOwnPropertyDescriptor` reads a property WITHOUT the `get` trap: `(result, "data")` returned the RAW collection and `(result, "subscription")` the handle `get` refuses - a second door into the same room, and the same hole existed on the collection view for the dunder hatch. And the array form says how many items it holds in four places (`length`, `Object.keys`, `in`, each index's descriptor) of which only `length` was filtered, so the count came back out through the other three and the descriptors handed back the items. All four traps now call ONE `read` function. Fixed in `e86e249`, with the regression pinned in both suites.
### The gate, verbatim
```
475 pass / 0 fail / 1367 expect() calls (bun test; baseline 465 pass / 0 fail, verified)
src exit=0 (pnpm exec tsc --noEmit -p packages/polyfill/tsconfig.json)
e2e exit=0 (pnpm exec tsc --noEmit -p packages/polyfill/e2e/tsconfig.json)
0 ERROR (python3 ~/.claude/skills/concept/lint.py; the same 11 pre-existing WARNINGs)
== Polyfill e2e summary: 47 passed, 0 failed, 47 total - batch 3.4 min, slowest cold sync 31s ==
(46 declared before this task; the extra row is "the read-filtered view refuses the
ORM's own subscription handle". Run three times, 47/47 each time, no login flake.)
== ORM live-read summary: 5 passed, 0 failed, 5 declared - run 0.6 min ==
(run twice; `useShape` ready in 11ms both times, the delete reaching the document)
```
Anti-cheat: no `any`, no `@ts-ignore`, no `as unknown as`, no widened type, no test deleted, skipped or weakened. Checked on the added lines with `git diff 5993a8c | grep "^+"`: the only `any` occurrences are two comments QUOTING the old `UseShapeLike`.
### Surprises
- **The shipped hook's `isLoading` contradicts its own documentation.** `useShape.d.ts` says "`true` when no data is available yet"; `useShape.js` computes `isLoading: !ormSubscription ? false : ormSubscription.isReady`, which is `true` once it IS ready. VERIFIED on both artifacts. Upstream's, not ours; reproduced as written in `e2e/orm-live-entry.ts` and flagged there, because a fake that quietly corrects the target measures something nobody ships.
- **`UseShapeResult` is exported from NO subpath of `@ng-org/orm`.** `.` is `core.d.ts`, `/react` re-exports the hook alone, `/all` re-exports `OrderByConfig`/`RdfOrmConfig`/`SubscriptionData`/`WhereConfig` only. VERIFIED on the manifest and the three `.d.ts`. The type is restated in `model/types.ts` for that reason.
- **The provenance gate forced a citation choice.** `ShapeResult` cannot cite `UseShapeResult`: the cited `nextgraph-rs` revision carries only `useShape` in that file, and `provenance.test.ts` checks that a citation RESOLVES there. It cites `useShape` with the version gap named in the note, which is what `OrmSubscriptionHandle` already does for the `OrmSubscription`/`RdfOrmSubscription` rename.
- **`1.0.0-dev.11` is a major and the entry says why**, including the sentence a consumer needs most: if they were reading `.data`, they were reading unfiltered.
### Follow-ups, out of scope
- T01.p item 1 gets easier: the published `useShape` now returns `ShapeResult`, which is NOT published from `index.ts`. Either publish it or decide the return type stays unnameable, and `DeepSignalSet` is still exported with no published signature using it.
- `e2e/polyfill-entry.ts` and `test/wallet-fake.ts` still inject `(() => {}) as unknown as UseShapeLike` at the call sites that never read a shape. Pre-existing, harmless, and now inconsistent with the one real fake.
- The collection view answers `has` as a filtering FUNCTION even on the array form, where the target has no such method. Not a leak (it filters), a shape it grows.
@@ -1,57 +0,0 @@
---
id: T01.o
title: The read filter is bypassed by plain properties, and no fake shows it
parent: T01
depends_on: []
created: 2026-09-24
claimed_by: null
claimed_at: null
blocked: false
---
## Description
**A rights leak on the published surface.** The capability filter around the published `useShape` is bypassed by the SDK version this repo actually builds against, and every gate stays green because no fake anywhere returns the shape the SDK ships. Found by T01.g, the closing review.
### The leak - VERIFIED from primary sources on both sides, do not re-derive
`packages/polyfill/src/emulated-verifier/read-filter.ts` does `const v = Reflect.get(target, prop, target); if (typeof v !== "function") return v;` - a non-function member is returned RAW, on the recorded ground that "a plain property carries no items".
That ground is true of the CITED tree (alpha.18, where `useShape` returns a bare `DeepSignalSet`) and FALSE of the INSTALLED `@ng-org/orm@0.1.2-alpha.21`. Its shipped `dist/frontendAdapters/react/useShape.d.ts` returns `UseShapeResult`, whose members include three PLAIN PROPERTIES that each carry the items: `data: SUBSCRIPTION_DATA | undefined`, `promise: Promise<SUBSCRIPTION_DATA>`, and `subscription: RdfOrmSubscriptionFor<...>`. Its own doc comment states that `data` IS the value returned by `RdfOrmSubscription.signalObject`.
Measured by T01.g with bob as holder and `isEnforcing() === true`: the alpha.18 shape iterates to `["bob-own"]`, while alpha.21's `.data`, `.subscription.signalObject` and `await .promise` each yield `["bob-own", "alice-private"]`, and nothing throws. That falsifies `contract_polyfill-surface.md:256` ("anything else throws").
### THE ASYMMETRY IS THE FAULT, and the code already argues against itself
For an unknown FUNCTION member the same file refuses rather than forwards, with the reason written beside it: forwarding is a silent leak, and this view's one job is that it cannot show more than the holder may read. That reasoning was never applied to non-function members. Naming the asymmetry is not the same as prescribing the repair - weigh what the right policy is, including what refusing `data` would cost a legitimate caller, and say what you rejected.
**Do NOT fix this by filtering three member names.** `data`, `promise` and `subscription` are alpha.21's shape; a name list is what breaks silently at alpha.22, and breaking silently on an SDK bump is exactly how this defect arrived. Whatever you build must be safe for a shape nobody has seen yet.
**The line that must hold:** a legitimate caller still gets its own data, through whichever member the SDK offers. A repair that makes the published `useShape` unusable has traded a leak for a break.
### Why it was invisible, which must be fixed in the same task
`packages/polyfill/e2e/orm-live-entry.ts:101-109` - the only live gate for the published `useShape` - injects a HAND-WRITTEN `sub.signalObject` substitute. So no fake in this repository returns the shape the SDK actually ships, and the leak cannot fail a test. Fixing the filter without fixing this leaves the next SDK shape change just as invisible.
Build a fake that returns the SHIPPED shape - read it from `node_modules/@ng-org/orm/dist/frontendAdapters/react/useShape.d.ts`, which is the artifact that settles it - and make it the one the gates use. A test that would not have caught this defect is not coverage.
### Two documentation faults that ride with it
- `contract_polyfill-surface.md` instructs injecting "the REAL @ng-org/orm useShape" (see `docs/api-contract.md:50`), but `@ng-org/orm`'s `.` export maps to `dist/core.d.ts`, which exports NO `useShape` - it lives at `/react`, or as `reactUseShape` from `/all`. The instruction does not resolve. CONFIRMED by T01.g.
- Four present-tense claims that the SDK's `useShape` returns a bare `DeepSignalSet` are false of alpha.21, and TWO are load-bearing provenance grounds: `use-shape-query.ts:284`'s `announced-not-written` premise, and `:193`'s `ShapeQuery kind=invention level=none ref=none`, justified as "no upstream type carries load state" when `UseShapeResult.isLoading` does. This is the `readUnion` failure mode again - a citation that resolves, attached to a claim that is false - and `provenance.test.ts` cannot see it by construction.
### Verification
**The leak's own test comes first and must fail on baseline.** Bob holding no capability for alice's document must not reach her items through ANY member of the returned object. Name the test.
**Hard gate:** `bun test` -> 0 fail, baseline `465 pass / 0 fail` which the orchestrator measured; `pnpm exec tsc --noEmit -p packages/polyfill/tsconfig.json` -> exit 0; the e2e tsconfig -> exit 0; `python3 ~/.claude/skills/concept/lint.py` -> 0 ERROR; and the polyfill e2e run and reported verbatim, since the injected fake changes. Broker login flakes roughly 3 runs in 10 before library code runs - re-run a flaked login.
Anti-cheat: no `any`, no `@ts-ignore`, no `as unknown as`, no deleted or skipped test, no weakened assertion. And specific to this one: no widening `UseShapeLike` to make a shape typecheck - `src/model/types.ts:83` is already `(...args:any[]) => any`, which is why both typechecks stayed at 0 while the leak was open, and that is a finding in itself worth addressing.
**A guarantee and the test that pins it land in the same commit.** The contract's claim at `:256` is currently false; it becomes true or it changes, in the same commit as its test.
**An independent adversarial review runs on this afterwards.** Rights defects get one whatever the gate says.
## Progress
## Result