fix: le dossier dit la verite sur le code, et la garde peut echouer

Cinq constats, aucun sur la frontiere des droits : le dossier et la garde.

LE MOTIF ENREGISTRE SUR `copyWithin` ETAIT FAUX, et c-etait raisonne, pas joue -
dans le meme lot que `rule_justify-a-return-value-by-running-it`, dont la
premiere application est a son propre voisin. Mesure : le vrai `readOnlyArray`
ne leve PAS, il execute et DETRUIT le premier element (["A","B","C"] devient
["B","C","C"]), tandis que `push`/`splice`/`sort`/`reverse`/`fill`/`shift`
levent a la lecture. `copyWithin` echappe a cette garde pour la raison exacte
qui le fait nommer ici : il est dans la meme liste mal etiquetee dont la garde
est faite. Le refuser est donc PLUS protecteur que la cible, pas equivalent :
une divergence deliberee, consignee comme telle, avec la mesure a cote.

LE FICHIER DOCUMENTAIT ENCORE LA REGLE QUE CE ROUND A SUPPRIMEE, et se
contredisait : deux en-tetes decrivaient le defaut par TYPE ("un compte, un
drapeau passent") et disaient que `isLoading` n-est pas nomme, alors que la
ligne 759 le nomme. Le contrat gardait les deux politiques a la fois. Une
doctrine qui survit a une reecriture est precisement ce qui egare le suivant.

LA GARDE NE VERIFIAIT PAS CE QU-ELLE ANNONCAIT. Prouve : une copie verbatim avec
{copyWithin, slice} en exceptions et `slice` retire de la liste servie passait
1 pass / 0 fail. Elle compare maintenant une PARTITION - l-ensemble effectif
amont egale l-union, les trois parts sont disjointes, l-exception vaut exactement
un membre nomme - et lit aussi les entrees SYMBOLE, invisibles a l-ancienne
expression. Verifie rouge dans les trois directions.

LA DEROGATION ECMASCRIPT ETAIT INFONDEE TELLE QUE RAISONNEE : la specification
ferme les CLES, pas les VALEURS. Un `Symbol.toStringTag` implemente en accesseur
rendant `Set(${size})` publiait le compte non filtre. Les deux cles sont
calculees a partir de la forme enveloppee. Et ce que la vue divulgue par
ailleurs - l-existence d-une cle sur la cible - est dit exactement, avec la
mesure qui montre qu-aucune presence de cle ne depend des objets.

`reduce` branchait sur `init === undefined` au lieu de l-arite.
This commit is contained in:
Sylvain Duchesne
2026-09-24 20:43:04 +02:00
parent 5c50ca67c0
commit a88b4497d9
4 changed files with 207 additions and 84 deletions
@@ -84,18 +84,20 @@ function cannotReachAnItem(v: unknown): boolean {
}
/**
* The keys the LANGUAGE reads, whose values are primitives and describe the object rather
* than its contents.
* WHAT THIS VIEW DISCLOSES BESIDE THE ITEMS, said exactly rather than claimed away.
*
* 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.
* A member the target does not carry answers `undefined` (see {@link answerUndecidedMember}),
* so a caller can tell a key the SDK has from one it does not: `view.by_a` refuses where
* `view.by_zzz` answers. That is deliberate, not an oversight - refusing a member nobody
* ships would refuse a question nobody asked - and it discloses the SDK's PUBLISHED SHAPE,
* which is in the package, not the holder's data.
*
* It is not a count channel either, and that is MEASURED rather than assumed: on every
* shape that ships, no key's PRESENCE depends on the items. `Object.keys(deepSignal(new
* Set(...)))` is empty whatever the set holds, and the array form's own keys are its
* indices, which this view projects onto the readable ones. If a future collection grew a
* key per item, that presence would become a count and this note is where to come back to.
*/
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. */
@@ -145,10 +147,16 @@ function writeRefusalOf(item: unknown): Error {
* 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.
* One thing is not a refusal: 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. What that discloses, and what it does not, is set out above.
*
* Note what is NOT here any more. A carve-out used to forward the target's own value for
* `Symbol.toStringTag` and `Symbol.isConcatSpreadable`, on the ground that the ECMAScript
* specification closes that key set. It closes the KEYS; it says nothing about the VALUES,
* so a tag implemented as a getter returning `Set(${size})` would have published the
* unfiltered count through a key this file had called harmless. Those two are DECIDED in
* the collection view now, with values the view computes from the shape it wraps.
*
* 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
@@ -157,7 +165,6 @@ function writeRefusalOf(item: unknown): Error {
function answerUndecidedMember(target: object, prop: string | symbol, where: string): unknown {
if (!(prop in target)) return undefined;
const v = Reflect.get(target, prop, target);
if (LANGUAGE_PRIMITIVE_KEYS.has(prop) && cannotReachAnItem(v)) return v;
if (typeof v === "function") {
return () => {
throw refusalOf(prop, where);
@@ -239,16 +246,30 @@ export const NON_MUTATING_ARRAY_ONLY: ReadonlySet<string> = new Set([
/**
* 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.
* WRITES.
*
* 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.
* ── What was recorded here, and what is actually true ─────────────────────
* This note used to say upstream's own `readOnlyArray` throws on reading `copyWithin`, so
* that refusing it merely matched the target. That was reasoned, not run, and it is FALSE.
* Measured against the installed package:
*
* ro.copyWithin -> function (no throw on the read)
* ro.copyWithin(0, 1) -> ["B","C","C"] (from ["A","B","C"] - the first
* element is DESTROYED)
* ro.push / splice / sort / reverse / fill / shift
* -> THROWS on the READ:
* "Readonly arrays do not expose non-mutating..."
*
* So `readOnlyArray` refuses every mutator it knows about - and `copyWithin` escapes that
* refusal for exactly the reason it is named here: it sits in the same mislabelled list the
* refusal is built from. Upstream's list and upstream's guard agree with each other and are
* both wrong.
*
* Refusing it is therefore MORE protective than the target, not equal to it: a deliberate
* divergence, recorded as one. The alternative is worse - serving it over the snapshot made
* it succeed and discard in silence, which teaches a caller that a write worked. Named here
* rather than dropped quietly, because the parse gate in `test/read-filter.test.ts` pins
* this exception by identity and cardinality.
*/
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
@@ -338,20 +359,25 @@ function filteredDescriptor(
* They are wired to one `read` function below rather than to three copies of the rule,
* because a boundary made of parallel switch statements is a boundary that drifts.
*
* ── Why the default is decided by TYPE, and not by a list of names ────────
* Until 2026-09-24 that reasoning was applied to FUNCTION members alone: everything else
* was forwarded raw, on the recorded ground that "a plain property carries no items". That
* ground was never a property of the model, only of the object that happened to be wrapped
* and the moment the wrapped object changed, three plain properties of it each carried
* every identity's items ({@link makeReadFilteredShapeResult}). A boundary that is safe for
* the shape in front of it and unsafe for the next one is not a boundary either.
* ── The default: DECIDED, or refused ─────────────────────────────────────
* Every member this file serves is named below. Anything else refuses, whatever its type.
* That is the whole rule, and {@link answerUndecidedMember} is where it lives.
*
* So the default is {@link cannotReachAnItem}, which asks of the VALUE what no version
* number can change: a primitive cannot hold a reference to anything, so it cannot reach an
* item and passes untouched - `size`, `isLoading`, a count, a flag, a name. An object or a
* function can, so it refuses unless this file has a filtered form of it. That is why
* nothing below names `isLoading`, and why a member nobody has seen yet is already decided:
* if it carries items it is an object or a function, and both refuse.
* Two earlier defaults are gone, and both are worth knowing because each read as obviously
* sound when it was written:
* 1. "a plain property carries no items" - true of the object that happened to be wrapped
* then, and false the moment the wrapped object changed, when three plain properties
* each carried every identity's items ({@link makeReadFilteredShapeResult});
* 2. "a primitive cannot reach an item, so it passes" - false because a COUNT is a
* primitive that DESCRIBES the items. The file already contradicted itself on this:
* `size` and `length` were computed, so a third spelling of the same number went
* through raw.
*
* 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 - so the default cannot be a type at all. What
* makes the current one safe for a shape nobody has seen is not that it classifies
* correctly, but that it refuses: a member the next version adds says so, loudly, naming
* itself.
*/
// @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 {
@@ -452,6 +478,13 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
* call this, so neither can answer what the other refuses. */
const read = (target: object, prop: string | symbol): unknown => {
if (prop === Symbol.iterator) return function* () { yield* kept(target); };
// HOW THE LANGUAGE NAMES THIS VIEW. Computed from the shape wrapped, never forwarded:
// the specification closes the KEY, not the value, so a tag implemented as a getter
// returning `Set(${size})` would have published the unfiltered count. Answered only
// when the target carries one, so the view grows no member the collection lacks -
// measured, a `DeepSignalSet` carries the constant "Set" and a `readOnlyArray` none.
if (prop === Symbol.toStringTag && prop in target) return arrayLike ? "Array" : "Set";
if (prop === Symbol.isConcatSpreadable && prop in target) return arrayLike;
// Upstream lists it beside `Symbol.iterator`. Answered only when the target carries
// one, so the view never grows an iteration the collection does not have.
if (prop === Symbol.asyncIterator && prop in target) {
@@ -515,10 +548,17 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
if (prop === "drop") return (n: number) => kept(target).slice(n);
if (prop === "flatMap") return (fn: (v: unknown, i: number) => unknown) => kept(target).flatMap(fn as never);
if (prop === "reduce") {
return (fn: (acc: unknown, v: unknown, i: number) => unknown, init?: unknown) =>
init === undefined
? kept(target).reduce(fn as never)
: kept(target).reduce(fn as never, init);
// On ARITY, not on `init === undefined`. Branching on the value made an EXPLICIT
// `reduce(fn, undefined)` look like the one-argument form: an owner with no readable
// items got "reduce of empty array with no initial value" where a Set or an Array
// answers `undefined`, and with one item got that item back without `fn` ever being
// called. Measured on both counts.
return function (this: unknown, ...args: unknown[]): unknown {
const fn = args[0] as never;
return args.length > 1
? kept(target).reduce(fn, args[1])
: kept(target).reduce(fn);
};
}
if (prop === "getById" || prop === "getBy") {
const inner = Reflect.get(target, prop, target) as ((...a: unknown[]) => unknown) | undefined;
@@ -705,13 +745,14 @@ function filteredPromise(value: PromiseLike<unknown>, caps: CapRegistry): Promis
* object cannot be both, so there are two views and this is the one `surface/use-shape.ts`
* applies.
*
* ── What each member answers, and why nothing here names `isLoading` ──────
* ── What each member answers ──────────────────────────────────────────────
* The three that carry the items are named and filtered; the two that command a page are
* named and forwarded, because they return `void` and load rows into a collection that is
* only ever seen through the filter. `isLoading` is NOT named: it is a boolean, and the
* default already passes anything that cannot reach an item. That is the whole point of
* deciding by type - a member added by a later version is already decided, and a name list
* would have been another thing to get right on the next bump.
* only ever seen through the filter; `isLoading` is named and served, because it describes
* the REQUEST rather than the items. All six of `alpha.21`'s members are therefore decided
* here, and a seventh that a later version adds refuses until someone decides it - which is
* the point, and the reason this list is not the "name list" the earlier type-based default
* was written to avoid. That default passed unknowns; this one refuses them.
*
* `subscription` is REFUSED rather than wrapped, and the reason is this surface's own: it
* is the SDK's `RdfOrmSubscription`, which this package deliberately does not publish
+6 -3
View File
@@ -3,9 +3,12 @@
* arguments. Once the cap emulation is in force, what comes back is a read-filtered VIEW
* over the RESULT ENVELOPE the hook returns - `data` and `promise` carry only items in
* documents the current holder has the `ReadCap` of, `subscription` is refused, and the
* page commands and the load state pass through. A member's DESCRIPTOR answers what
* reading it answers, so the spread and the inspections built on that call read through
* the view rather than round it. Before the first cap is issued the real
* page commands and the load state pass through. A member's DESCRIPTOR answers what reading
* it answers, so what is built on that call reads through the view rather than round it -
* which for the envelope means a SPREAD of the result THROWS on `subscription` rather than
* copying it out. What no trap reaches is the HOST's own introspection: `console.log` and a
* devtools inspector read the object behind the proxy and print every identity's items
* (measured on Bun/JSC). The contract says so; there is nothing here to fix. Before the first cap is issued the real
* result passes through unchanged. At migration the filtering disappears: the broker only
* delivers documents whose cap the wallet holds.
*