Files
ng-eventually/.project/concepts/app-contract/knowledge_what-stands-between-identities.md
2026-10-01 13:53:02 +02:00

11 KiB

type, summary, last_checked
type summary last_checked
knowledge 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 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.