22 KiB
id, title, parent, depends_on, created, claimed_by, claimed_at, blocked
| id | title | parent | depends_on | created | claimed_by | claimed_at | blocked |
|---|---|---|---|---|---|---|---|
| T01.o | The read filter is bypassed by plain properties, and no fake shows it | T01 | 2026-09-24 | worker-T01.o | 2026-09-24 | 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.mdinstructs injecting "the REAL @ng-org/orm useShape" (seedocs/api-contract.md:50), but@ng-org/orm's.export maps todist/core.d.ts, which exports NOuseShape- it lives at/react, or asreactUseShapefrom/all. The instruction does not resolve. CONFIRMED by T01.g.- Four present-tense claims that the SDK's
useShapereturns a bareDeepSignalSetare false of alpha.21, and TWO are load-bearing provenance grounds:use-shape-query.ts:284'sannounced-not-writtenpremise, and:193'sShapeQuery kind=invention level=none ref=none, justified as "no upstream type carries load state" whenUseShapeResult.isLoadingdoes. This is thereadUnionfailure mode again - a citation that resolves, attached to a claim that is false - andprovenance.test.tscannot 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: REFUTED once, repaired, and back for a fresh adversary. First pass was 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
UseShapeResultand aDeepSignalSethave nothing in common - the collection view threw on[...result]and.sizebecause an envelope is not iterable. One object cannot be both, so there are two views andsurface/use-shape.tsapplies the envelope one. - Wrapping
subscriptionrather than refusing it. It reaches the items three ways (signalObject,readyPromise,addChangeListener's raw arrays) and this surface already saysRdfOrmSubscriptionis 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
undefinedfor 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
isLoadingcontradicts its own documentation.useShape.d.tssays "truewhen no data is available yet";useShape.jscomputesisLoading: !ormSubscription ? false : ormSubscription.isReady, which istrueonce it IS ready. VERIFIED on both artifacts. Upstream's, not ours; reproduced as written ine2e/orm-live-entry.tsand flagged there, because a fake that quietly corrects the target measures something nobody ships. UseShapeResultis exported from NO subpath of@ng-org/orm..iscore.d.ts,/reactre-exports the hook alone,/allre-exportsOrderByConfig/RdfOrmConfig/SubscriptionData/WhereConfigonly. VERIFIED on the manifest and the three.d.ts. The type is restated inmodel/types.tsfor that reason.- The provenance gate forced a citation choice.
ShapeResultcannot citeUseShapeResult: the citednextgraph-rsrevision carries onlyuseShapein that file, andprovenance.test.tschecks that a citation RESOLVES there. It citesuseShapewith the version gap named in the note, which is whatOrmSubscriptionHandlealready does for theOrmSubscription/RdfOrmSubscriptionrename. 1.0.0-dev.11is 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
useShapenow returnsShapeResult, which is NOT published fromindex.ts. Either publish it or decide the return type stays unnameable, andDeepSignalSetis still exported with no published signature using it. e2e/polyfill-entry.tsandtest/wallet-fake.tsstill inject(() => {}) as unknown as UseShapeLikeat the call sites that never read a shape. Pre-existing, harmless, and now inconsistent with the one real fake.- The collection view answers
hasas a filtering FUNCTION even on the array form, where the target has no such method. Not a leak (it filters), a shape it grows.
Round 2 - the review refuted the first delivery, and it was right
FINDING 1 was a real rights leak and it is closed. Set.prototype.add returns this, so the three mutations - forwarded BOUND TO THE TARGET - handed the unfiltered collection back as add's return value. Every way IN was filtered and the way OUT gave the whole set away, through the FIRST member the ORM's own documentation example uses (expenses.add({...})).
Verified before changing anything, by running it rather than reading it:
Set.add returns this: true DeepSignalSet.add returns === the deepSignal proxy: true
Set.delete returns: boolean DeepSignalSet.delete returns: boolean true
Set.clear returns: undefined DeepSignalSet.clear returns: undefined
So one of the three was unsafe in the way the reviewer said, and the other two were safe in their RETURN and unsafe in their REACH - which the reviewer's own repro had shown (delete(aliceItem) returned true and removed it). The rule is therefore one sentence covering all three: a mutation reaches the collection underneath, but neither hands back nor acts past what the view shows.
addhands back the VIEW, so chaining lands on the filtered collection.deleteacts only on an item the view shows - the same questionhasalready declines to answer, and the two used to disagree.clearempties the view, not the collection every identity shares.
Writing still reaches the document: e2e/orm-live-read.ts 5 passed, 0 failed against the deployed broker, with .delete() removing the triples.
The reviewer's own repro, replayed through the published call: add returns the raw collection : false, and what it hands back holds only bob's items.
THE PATTERN, taken seriously. Five leaks, one cause: a WRITTEN JUSTIFICATION ABOUT A RETURN VALUE NOBODY EXECUTED. So the first thing done here was to run the return values, and the second was to run a SWEEP of every handled member on both proxies asking "does this hand back the target?". It found one more, on the other proxy: a page command of the ENVELOPE that returned the collection handed it back RAW. nextPage is declared () => void and yields nothing today, so it is not a live defect - it is the same unexecuted ground on the other side, and it is now closed with a test that pins the RULE. Three copies of that reasoning are now ONE function, handBack.
THE THREE MINORS, all fixed:
- The array form answered
keys/entries/forEachwith SET semantics. It now yields indices,[index, item], and the index asforEach's second argument. Owner-path output, no leak. - The shared fake was more correct than the target -
isLoading: !loadedagainst the shippedormSubscription.isReady. That re-creates the exact blindness this task removes, and the reviewer is right that it is worse than minor in kind. The fake now reproduces the shipped expression, the file says the inversion is upstream's, and the test asserts the inverted value on purpose. The contract carries it as a non-guarantee telling a consumer to branch ondata === undefinedinstead. - Ten stale
as unknown as UseShapeLike, not two. All ten take the shared fake, with no cast.
The contract was FALSE and is now true, verified by running it. Every clause of :263 has a test that fails if the clause is false; the two newest were demonstrated failing against the pre-fix code before the fix was written. "mutations pass through" is gone, replaced by what the mutations actually do.
Round 2 gate, verbatim
480 pass / 0 fail / 1393 expect() calls, six consecutive runs (baseline 465 pass / 0 fail)
src exit=0 e2e exit=0 concept lint: 0 ERROR
== Polyfill e2e summary: 47 passed, 0 failed, 47 total ==
== ORM live-read summary: 5 passed, 0 failed, 5 declared - run 0.6 min ==
What round 2 did NOT re-open
The twenty-five access routes the review found closed were left alone, and the sweep re-confirmed that no member of the collection view hands back the target.
Still open, named rather than fixed
delete and clear now act only on what the view shows. That is a WRITE-side narrowing justified by the read boundary, and it is the one place this task went beyond the finding: it was done because has already refuses the same question, so the view was contradicting itself. A fresh adversary should weigh whether a holder losing the ability to clear a collection they cannot fully see is the right trade.