fix: ce qui n-est pas DECIDE est refuse, et les deux vues portent la meme regle

LE PRINCIPE ETAIT FAUX, pas un nom manquant. "Un primitif ne peut atteindre
aucun objet, donc il passe" : or un COMPTE est un primitif qui DECRIT les
objets. Le fichier le savait deja, puisque `size` et `length` sont calcules - par
une liste de deux noms, c-est-a-dire la liste de noms interdite reapparue en
repli des que le principe a cede. Tout ce qui s-epelle autrement passait :
mesure, un porteur qui peut lire 1 objet sur 3 lisait `totalCount` a 3. Et LA
SUITE EPINGLAIT LA REPONSE QUI FUIT COMME CORRECTE, six lignes au-dessus d-un
test dont le commentaire appelle ce meme compte non filtre le defaut.

Aucun test de TYPE ne separe un primitif qui est une valeur d-un primitif derive
de la collection : un nombre est un nombre. Dit clairement, parce que la reponse
precedente pretendait le contraire. Le defaut s-inverse donc : ce que ce fichier
a DECIDE, il le sert ; tout le reste refuse, primitif ou non. C-est sur pour une
forme que personne n-a vue PAR CONSTRUCTION. Deux exceptions, aucune n-etant une
epellation du SDK : un membre que la cible ne porte pas rend `undefined`, et les
cles de protocole du LANGAGE, fermees par la specification.

LES DEUX VUES, PAS UNE. L-enveloppe avait deux pieges contre neuf : `delete
resultat.data`, `resultat.isLoading = x`, `setPrototypeOf` et
`preventExtensions` atteignaient l-objet du SDK - dont les deux derniers avaient
ete fermes sur la collection au round precedent. Les cinq pieges d-ecriture
vivent maintenant dans UN objet etale dans les deux.

`add` demandait `keep`, qui repond vrai pour un objet ne nommant AUCUN document :
juste pour une lecture, vide de sens pour une ecriture. L-objet atterrissait dans
la collection partagee, et seul l-amont l-arretait - la garantie empruntee que ce
fichier avait justement retiree un round plus tot.

Enfin `copyWithin`, que la liste amont classe non mutant a tort et que son propre
`readOnlyArray` refuse : il n-est plus servi.
This commit is contained in:
Sylvain Duchesne
2026-09-24 20:10:13 +02:00
parent 7a37e44ac0
commit a4ec2546bf
4 changed files with 265 additions and 74 deletions
@@ -74,12 +74,8 @@ export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[] {
}
/**
* Can this value NOT reach an item, whatever it turns out to be?
*
* The one question a filter can ask about a member it has never seen. A primitive holds no
* reference to anything, so no item is reachable through it and it may pass. An object or a
* function may hold one - directly, behind a promise, behind a getter, behind a whole
* subscription - so it may not pass unless this file has a filtered form of it.
* Is this value a primitive? Used where the question really is "can this hold a reference",
* never as a licence to pass a member through - see {@link answerUndecidedMember}.
*
* `null` is a primitive in the model and `typeof null` is `"object"`, so it is named.
*/
@@ -87,6 +83,20 @@ function cannotReachAnItem(v: unknown): boolean {
return v === null || (typeof v !== "object" && typeof v !== "function");
}
/**
* The keys the LANGUAGE reads, whose values are primitives and describe the object rather
* than its contents.
*
* This is not an SDK spelling list and cannot become one: it is closed by the ECMAScript
* specification, not by `@ng-org/orm`, so no version bump adds to it. Without it a view
* cannot be printed, tagged or concatenated, and refusing those refuses a question nobody
* asked about the items.
*/
const LANGUAGE_PRIMITIVE_KEYS: ReadonlySet<string | symbol> = new Set([
Symbol.toStringTag,
Symbol.isConcatSpreadable,
]);
/** What a refused member says. One wording for both shapes of refusal, so that a report of
* either is greppable as the same event. */
function refusalOf(prop: string | symbol, where: string): Error {
@@ -103,24 +113,51 @@ function refusalOf(prop: string | symbol, where: string): Error {
function writeRefusalOf(item: unknown): Error {
const doc = docOf(item);
return new Error(
"[ng-eventually] read filter: refused - this identity does not hold the cap of the " +
`document this object names, so writing it there is a write into a document it ` +
`cannot read. Naming a document does not grant access to it. ${JSON.stringify(doc)}`,
doc === null
? "[ng-eventually] read filter: refused - this object names no document in `@graph`, " +
"so there is no document that owns it and nothing to check a cap against: it would " +
"land in the collection every identity shares. Set `@graph` to a document you hold."
: "[ng-eventually] read filter: refused - this identity does not hold the cap of the " +
"document this object names, so writing it there is a write into a document it " +
`cannot read. Naming a document does not grant access to it. ${JSON.stringify(doc)}`,
);
}
/**
* The answer for a member this file does not name: decided by the TYPE of what is behind it,
* never by its name. See the note on {@link makeReadFilteredView}.
* The answer for a member this file has NOT DECIDED: refuse it, whatever its type.
*
* ── Why the type-based default was wrong, and what replaces it ────────────
* It used to be "a primitive cannot reach an item, so it passes". That is false, and the
* file already knew it: a COUNT is a primitive that describes the items, which is why
* `size` and `length` are computed rather than forwarded. Anything spelled otherwise went
* straight through - measured 2026-09-24, a holder who may read one of three items read
* `totalCount` as 3 - and `@ng-org/orm` already ships `pageSize`, `maxActivePages` and
* `nextPage`, so a total-count member is an ordinary next bump rather than a hypothesis.
*
* 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. Said plainly because the previous
* answer pretended otherwise - and because the repair is not another name in a list.
*
* So the default inverts. **What this file has decided, it serves; everything else it
* refuses**, primitive or not. That is safe for a shape nobody has seen BY CONSTRUCTION,
* which a passing default never was: what the next version adds refuses, loudly, naming
* itself. The prohibition worth keeping is on a list that lets unknowns THROUGH - that one
* breaks silently on a bump, which is how this whole defect arrived.
*
* Two things are not refusals, and neither is an SDK spelling:
* - a member the target does NOT CARRY answers `undefined`, because the SDK offers no
* such member and there is nothing behind it to derive anything from;
* - {@link LANGUAGE_PRIMITIVE_KEYS}, closed by the language rather than by any package.
*
* A function is refused AT THE CALL rather than at the read, which is not politeness: a
* function value is read by things that never call it - a logger, a devtools inspection, a
* `typeof` guard - and refusing those would be refusing a question nobody asked. An object
* has no such later moment, so it is refused at the read.
* `typeof` guard - and refusing those would be refusing a question nobody asked.
*/
function answerUnnamedMember(target: object, prop: string | symbol, where: string): unknown {
function answerUndecidedMember(target: object, prop: string | symbol, where: string): unknown {
if (!(prop in target)) return undefined;
const v = Reflect.get(target, prop, target);
if (cannotReachAnItem(v)) return v;
if (LANGUAGE_PRIMITIVE_KEYS.has(prop) && cannotReachAnItem(v)) return v;
if (typeof v === "function") {
return () => {
throw refusalOf(prop, where);
@@ -132,6 +169,46 @@ function answerUnnamedMember(target: object, prop: string | symbol, where: strin
/** `"0"`, `"12"` - an array index, in the canonical form a property key takes. */
const INDEX = /^(0|[1-9][0-9]*)$/;
/**
* The traps that stop a WRITE reaching the object underneath, written ONCE and spread into
* both views.
*
* Written once because the last three defects in this file were all one shape: a rule
* applied in one of two places. These five were added to the collection view and not to the
* envelope, so on the result object `delete result.data` succeeded, `result.isLoading = x`
* wrote to the SDK's own object, and `Object.setPrototypeOf` / `Object.preventExtensions`
* reached it too - the very pair the round before had found and trapped, on one view of
* two. A spread object cannot be added to one and forgotten on the other.
*
* Nothing legitimate is lost on either: a caller mutates an ITEM (handed out whole, and
* only when readable) or calls a member; writing to the view's own properties is not a
* gesture the SDK or this surface offers.
*/
const writeTraps: Pick<
ProxyHandler<object>,
"set" | "deleteProperty" | "defineProperty" | "setPrototypeOf" | "preventExtensions"
> = {
set(_target, prop) {
throw refusalOf(prop, "a write to the view itself");
},
deleteProperty(_target, prop) {
throw refusalOf(prop, "a write to the view itself");
},
defineProperty(_target, prop) {
throw refusalOf(prop, "a write to the view itself");
},
// The two that reach the target without naming a property: `setPrototypeOf` replaced the
// real collection's prototype, after which iterating it throws for EVERY identity, and
// `preventExtensions` - what `Object.freeze` begins with - both writes to the target and
// strands the index projection, which is only lawful while the target is extensible.
setPrototypeOf() {
throw refusalOf("the prototype", "a write to the view itself");
},
preventExtensions() {
throw refusalOf("extensibility", "a write to the view itself");
},
};
/**
* The members `@ng-org/alien-deepsignals` declares NON-MUTATING, restated.
*
@@ -157,8 +234,23 @@ export const ITERATOR_HELPERS: ReadonlySet<string> = new Set([
export const NON_MUTATING_ARRAY_ONLY: ReadonlySet<string> = new Set([
"at", "concat", "entries", "findIndex", "findLast", "findLastIndex", "flat", "indexOf",
"join", "keys", "lastIndexOf", "reduceRight", "toLocaleString", "toReversed", "toSorted",
"toSpliced", "slice", "values", "with", "copyWithin",
"toSpliced", "slice", "values", "with",
]);
/**
* The ONE member upstream lists as non-mutating that is not: `Array.prototype.copyWithin`
* writes. Upstream's own `readOnlyArray` agrees in practice - it THROWS "Cannot modify
* readonly array" when you so much as read `copyWithin` off it - so the list and the
* implementation next to it disagree, and the list is the one that is wrong.
*
* Serving it over the snapshot made it succeed and discard silently, which teaches a caller
* that a write worked when the collection it ships against refuses the same call. So it is
* not served: reading it off a real ordered collection raises upstream's own error, and off
* anything else it is refused like any other undecided member. Named here rather than
* dropped quietly, because the parse gate in `test/read-filter.test.ts` checks for exactly
* this one exception and nothing more.
*/
export const UPSTREAM_MISLABELLED_AS_NON_MUTATING: ReadonlySet<string> = new Set(["copyWithin"]);
/** Upstream's set-only additions - the ES2025 combinators, which need a real `Set` to run
* on, plus the pair spellings handled explicitly below. */
export const NON_MUTATING_SET_ONLY: ReadonlySet<string> = new Set([
@@ -264,6 +356,24 @@ function filteredDescriptor(
// @provenance makeReadFilteredView kind=aligned level=1 ref=engine/verifier/src/verifier.rs:load_repo_from_read_cap — the same filter as a live view over a reactive set; removed at migration
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S {
const keep = (item: unknown): boolean => readable(item, caps);
/**
* May this identity WRITE this object into the collection?
*
* NOT {@link readable}, and the difference is the whole of the finding it answers. 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: an
* object carrying no `@graph` named no document, so it passed, landed in the collection
* every identity shares and became visible to all of them. The only thing stopping the
* plant was UPSTREAM raising "The object's @graph is missing and could not be inferred"
* - a guarantee borrowed from the target, which is exactly what this file removed for
* `set`/`deleteProperty` one round earlier and then left standing here.
*
* So a write must NAME a document and the holder must hold it.
*/
const writable = (item: unknown): boolean => {
const doc = docOf(item);
return doc !== null && caps.capFor(doc) !== undefined;
};
/** The readable items, as a plain array — what every handled member works from. */
const kept = (target: object): unknown[] => {
const out: unknown[] = [];
@@ -448,7 +558,7 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
if (typeof fn !== "function") return fn;
// `add` chains, so its result is the collection and must become the view.
return (...args: unknown[]): unknown => {
if (!keep(args[0])) throw writeRefusalOf(args[0]);
if (!writable(args[0])) throw writeRefusalOf(args[0]);
return handBack((fn as (...a: unknown[]) => unknown).apply(target, args), target, prop);
};
}
@@ -506,7 +616,7 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
// ANY OTHER MEMBER, function or not: refuse rather than forward, unless the value
// behind it cannot reach an item at all. See the header — forwarding is a silent
// leak, and this view's one job is that it cannot show more than the holder may read.
return answerUnnamedMember(target, prop, "the collection view");
return answerUndecidedMember(target, prop, "the collection view");
};
const proxy = new Proxy(set, {
@@ -522,39 +632,9 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
const i = indexOf(prop);
return i === null ? Reflect.has(target, prop) : i < kept(target).length;
},
// WRITES THROUGH A PROPERTY, on this view's own account.
//
// There were no such traps until 2026-09-24, and against an ordered result that is a
// plain array a holder could do `view[0] = x`, `delete view[0]` and `view.length = 0`
// - the last one defeating the gated `clear` outright. What ships is a `readOnlyArray`
// that refuses those itself, so production was protected BY THE TARGET: the view was
// resting on a guarantee it did not make, about a shape it does not control. That is
// the same borrowed assumption as every other defect in this family, so the view now
// carries it. Nothing legitimate is lost: a caller mutates an ITEM (handed out whole,
// and only when readable) or calls `add`/`delete`/`clear`; writing to the collection's
// own properties is not a gesture either the SDK or this surface offers.
set(_target, prop) {
throw refusalOf(prop, "a write to the view itself");
},
deleteProperty(_target, prop) {
throw refusalOf(prop, "a write to the view itself");
},
defineProperty(_target, prop) {
throw refusalOf(prop, "a write to the view itself");
},
// The two that reach the target without naming a property, found by sweeping this
// file's own repair rather than by reasoning about it: `Object.setPrototypeOf(view, other)`
// replaced the real collection's prototype - after which `[...collection]` throws for
// EVERY identity, so it is the `clear` problem again in a different key - and
// `Object.preventExtensions(view)` (which is what `Object.freeze` starts with) made
// the target non-extensible, which both writes to it and breaks `ownKeys` below, whose
// projection is only lawful while the target is extensible.
setPrototypeOf() {
throw refusalOf("the prototype", "a write to the view itself");
},
preventExtensions() {
throw refusalOf("extensibility", "a write to the view itself");
},
// A WRITE to the collection itself, refused on this view's own account rather than
// because the ordered collection upstream happens to refuse some of them too.
...writeTraps,
ownKeys(target) {
// A Set holds its items internally and owns no key naming one, so there is nothing
// to project: `Object.keys` on it is already empty (measured). An array owns one key
@@ -671,6 +751,13 @@ export function makeReadFilteredShapeResult<R extends object>(result: R, caps: C
"`useShapeQuery`.",
);
}
// THE REQUEST'S OWN STATE. Decided rather than left to a default, which is the whole
// repair: it is a boolean and it used to pass because it is a primitive, on a rule
// that also passed `totalCount`. It is served because it describes the REQUEST - has
// the payload arrived - and not the items, so it says nothing about what was filtered
// out. (Its VALUE is upstream's own inversion; see `ShapeResult.isLoading`.)
if (prop === "isLoading") return Reflect.get(target, prop, target);
// THE PAGE COMMANDS. They forward: what they load lands in the raw collection, which
// is only ever read back through the filter above.
//
@@ -690,12 +777,16 @@ export function makeReadFilteredShapeResult<R extends object>(result: R, caps: C
throw refusalOf(prop, "the result envelope");
};
}
return answerUnnamedMember(target, prop, "the result envelope");
return answerUndecidedMember(target, prop, "the result envelope");
};
return new Proxy(result, {
get: (target, prop) => read(target, prop),
getOwnPropertyDescriptor: (target, prop) =>
filteredDescriptor(target, prop, () => read(target, prop)),
// The SAME write traps as the collection view, from the same object. This view had two
// traps to the other's nine, so the SDK's own result could be deleted from, written to
// and re-prototyped through it.
...writeTraps,
}) as R;
}