62 lines
11 KiB
Markdown
62 lines
11 KiB
Markdown
---
|
|
type: knowledge
|
|
summary: One module stands between identities sharing a wallet - its rule lives in TWO views that must be repaired together, its default is decided-or-refused because no type test separates a count from a config echo, and host introspection is the one hole no trap can close
|
|
last_checked: 2026-10-01
|
|
---
|
|
|
|
# What stands between identities on a read
|
|
|
|
This package emulates a world in which each identity reads only its own data, while physically every identity shares ONE wallet that opens everything. `emulated-verifier/read-filter.ts` is the whole of what stands between them. Three ways to break it, and all three happened while it was being built: a READ hole discloses another identity's objects, a WRITE hole lets one identity plant into or destroy a document it cannot read, and an OVER-BROAD refusal takes the owner's own data away from them. A repair that removes a leak by refusing more is not a repair - the third cost the owner 18 declared members of an ordered result. This leaf is why the module has the shape it has, so that an agent changing it keeps the shape.
|
|
|
|
## TWO views, one rule, and the defect that is always the same
|
|
|
|
The module publishes two proxies, and the distinction is not cosmetic: `makeReadFilteredView` wraps a COLLECTION (a `DeepSignalSet`, or the array-like `DeepSignal<ReadOnlyArray>` an `orderBy` config produces), and `makeReadFilteredShapeResult` wraps the SDK's result ENVELOPE, whose members are of two kinds - some carry the items, some are the request's own state. One object cannot be both: the envelope was wrapped in the collection view until `1.0.0-dev.10`, so `[...result]` and `.size` threw while `data`, `promise` and `subscription` each came back raw, each carrying every identity's items.
|
|
|
|
**Every defect in this family has been one rule applied to one view of two.** So what can be shared is shared as CODE rather than as a convention: `writeTraps` is a single object spread into both proxies, and `answerUndecidedMember`, `filteredDescriptor`, `cannotReachAnItem` and `readable` are one function each, called from both. What is NOT shared is the member decisions, because the two shapes answer different questions. The discipline that follows: **a defect found in one view is checked in the other before it is called fixed** - the round that added five write traps to the collection view and not to the envelope had just found and trapped two of them, on one view of two.
|
|
|
|
## The default is DECIDED-OR-REFUSED, and the type-based alternative was tried
|
|
|
|
What the file has decided it serves, it serves; **everything else refuses, whatever its type**. The one exception is a member the target does not carry at all, which answers `undefined` - there is nothing behind it to derive anything from, and refusing it would refuse a question nobody asked.
|
|
|
|
Keep the reason, because the obvious alternative shipped and failed. The default used to be *"a primitive cannot reach an item, so it passes"*. **There is no type-level test that separates a primitive which is a value from one derived from the collection**: a number is a number, and nothing observable about a getter says whether its 7 counts items or echoes a page size. The file already contradicted itself on this - `size` and `length` were computed precisely because a count describes the items - so a third spelling went through raw, and a holder entitled to one of three items read `totalCount` as 3. `@ng-org/orm` already ships `pageSize`, `maxActivePages` and `nextPage`, so a count member is an ordinary next bump rather than a hypothesis.
|
|
|
|
The asymmetry that decides it: **a default that lets an unknown member THROUGH breaks silently at the next SDK bump; one that refuses it breaks loudly, naming itself.** That is what makes the current default safe for a shape nobody has seen - not that it classifies correctly, but that it refuses. See [[justify-a-return-value-by-running-it]], whose third corollary is this rule and four of whose five cases are this module.
|
|
|
|
A function is refused AT THE CALL rather than at the read, deliberately: a function value is read by things that never call it - a logger, a devtools inspection, a `typeof` guard.
|
|
|
|
## Nine traps on the collection, seven on the envelope, and which share one answer
|
|
|
|
`get` is not the only door. `Object.getOwnPropertyDescriptor` - and the spread, `Object.keys`, `Object.entries`, `JSON.stringify` and a console inspection built on it - reads a property WITHOUT going through `get`, which is how one leak was found: a view that filtered `get` alone handed the raw collection back to whoever asked for the descriptor, and handed over the ORM handle that `get` throws on. So the two VALUE-bearing doors are wired to ONE `read` function per view, passed into `filteredDescriptor` as the very function `get` calls, and they cannot drift. That holds for an ACCESSOR as much as for a value - a descriptor carries no value of its own, so handing one back untouched was a door around the view, and its getter is re-pointed at the view's own read while its setter is refused.
|
|
|
|
The collection view carries **nine** traps: `get`, `getOwnPropertyDescriptor`, `has`, `ownKeys`, plus the five writes. The envelope carries **seven** - the same five writes and the same two reads, and no `has` or `ownKeys`, because those are questions about POSITIONS and an envelope has none. Be precise about the routing rather than repeating the header's own wording: `has` and `ownKeys` do not go through `read`; they answer from the same `kept` projection, which is what keeps an index, `length`, `in` and `Object.keys` telling one story on the array form. Forwarding an array's own keys published how many items the collection held while `length` said how many may be read - the two disagreed by exactly the leak. Membership of a NON-index key forwards, deliberately: it discloses the SDK's published shape, which is in the package rather than in the holder's data.
|
|
|
|
The five writes are `set`, `deleteProperty`, `defineProperty`, `setPrototypeOf` and `preventExtensions`. The last two are there for a different reason from the first three - they reach the target without naming a property and **damaged the real collection for EVERY identity rather than leaking**: `setPrototypeOf` replaced its prototype, after which iterating it throws for everyone, and `preventExtensions` (what `Object.freeze` begins with) strands the index projection, which is lawful only while the target is extensible.
|
|
|
|
## A mutation asks the question the view already answers
|
|
|
|
`add`, `delete` and `clear` reach the collection underneath - the view must not break writes or the reactivity they drive - under one rule: **a mutation may not act past what the view shows, and may not hand back what the view hides.** Gating two of three was the asymmetry rather than a half-measure: an ungated `add` MANUFACTURED the state the other two then handled partially, so a holder could plant an item into a document it cannot read, never see it again, and watch `clear()` leave it behind.
|
|
|
|
`add`'s gate is the stricter form of the same question, and the difference is load-bearing. On the READ side an item with no `@graph` is KEPT, because it names no document and so there is no cap to ask about; on the WRITE side that reading makes the gate VACUOUS - the object landed in the collection every identity shares. So a write must NAME a document and the holder must hold it.
|
|
|
|
They answer differently **by their own contracts, not by residual asymmetry**: `delete` returns `false`, the Set contract's own word for "it was not there" and true of the view, while `add` returns the collection whether or not it acted, so it has no such value and THROWS rather than report a write that did not happen. `add`'s return is also why anything a member hands back is held to the rule: `Set.prototype.add` returns `this`, so the result becomes the VIEW - the collection, handed back, is every identity's items.
|
|
|
|
## The restated member lists, and how to verify this leaf cheaply
|
|
|
|
Every member `@ng-org/alien-deepsignals` declares non-mutating is SERVED, computed over the readable items against a fresh `Array` or `Set` built from `kept` - refusing them is what cost the owner those 18 members. The three lists are restated rather than imported, because the package's `exports` map has no subpath for `iteratorHelpers` and `src/` may not import an optional peer at runtime anyway.
|
|
|
|
**A restatement drifts unless something reads the original, so something does, and that is the cheap way to check this leaf:** `test/read-filter.test.ts`, "the declared non-mutating surface still matches what upstream ships", parses the shipped `dist/iteratorHelpers.js` and asserts a PARTITION - upstream's effective list equals the union exactly, the three parts are disjoint, the exception set is exactly one named member, and the SYMBOL entries are read too. The partition is the point: comparing each list against upstream minus the exceptions is satisfied by any number of exceptions, so a copy with `{copyWithin, slice}` excepted and `slice` dropped from the served list ran green while `data.slice()` had silently gone from the owner.
|
|
|
|
## ONE deliberate divergence, measured
|
|
|
|
`copyWithin` is REFUSED although upstream's list declares it non-mutating. Re-measured against the installed package on 2026-10-01: `ro.copyWithin` reads as a `function` with no throw, and `ro.copyWithin(0, 1)` turns `["A","B","C"]` into `["B","C","C"]` - the first element is destroyed - while `push`, `splice`, `sort`, `reverse`, `fill` and `shift` all THROW on the READ. Upstream's `readOnlyArray` refuses every mutator it knows about, and `copyWithin` escapes that refusal because the guard is built from the same mislabelled list the name sits in: upstream's list and upstream's guard agree with each other and are both wrong.
|
|
|
|
So refusing it is **STRICTER than the target**, not equal to it. Record it as a deliberate divergence with its measurement and never as equivalence - this is the one place in the module where [[no-divergence-from-nextgraph]] is answered by diverging, and the alternative was worse: serving it over the snapshot made it succeed and discard in silence, teaching a caller that a write worked.
|
|
|
|
## What it cannot close, and what the contract therefore tells applications
|
|
|
|
**Host introspection.** `console.log(result)` inspects the object BEHIND the proxy and prints every identity's items, and no trap can prevent it - re-measured on 2026-10-01 against `makeReadFilteredView`, where the view yields one item through every trapped path and `console.log(view)` prints `Set(2)` with both. It is a property of runtime introspection, not a hole a version can close, and the published contract already says so: **do not log or inspect what a `shape read` hands you when more than one identity shares the page** - iterate `data` and log what you mean to show.
|
|
|
|
Two smaller limits, neither a hole: the filter is SYNCHRONOUS, so a public-store document whose `ReadCap` nobody has asked for yet is dropped until some read path asks; and refusing at the ASK is a different question from filtering the RESULT - under `did:ng:i` nothing is named and this filter is the whole answer, while on a named scope the sync barrier decides whether the document is an answer at all (`knowledge_one-answer-to-whether-a-document-is-open`) and the filter still keeps that answer honest per `@graph`.
|
|
|
|
The whole module is `polyfill-era` and goes at migration, when the broker delivers only what the holder's keys open (`knowledge_what-an-app-deletes-at-migration`). The caller-facing form of everything here is `polyfill-surface/contract_polyfill-surface` `## Guarantees`.
|