fix(plugin-security)!: refuse a sys_user_position write whose position names no sys_position row (#16712) - #20292
Conversation
… names no sys_position row Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
…ine, with the ruling's control leg Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
…g on sys_user_position.position Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
… the field code where the casing gate owns it, type the test's query options Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 9 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cf3ea1eff1f7d4d1c5c223b6df4e02ad5f2bcd36 && git checkout cf3ea1eff1f7d4d1c5c223b6df4e02ad5f2bcd36
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f c282e60807cd32e36a83a502f0e02b59b75cef95 && git checkout -B drift-repro d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f && git merge --no-ff c282e60807cd32e36a83a502f0e02b59b75cef95
node scripts/docs-audit/affected-docs.mjs --json d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f
|
|
Seat note (
Generated by Claude Code |
The one conflict was the system-context census file-count row; the branch's side was taken and the census is re-derived from the merged tree in the next commit. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
…ols 88 -> 89) main moved a read into a new symbol while this branch added one; the text merge kept "88" from both sides. gen:system-context-census re-derives it. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
…ization plus organization-less rows
The refusal's catalog reads (the name check and the id hint) now run under
{ ...context, isSystem: true }, the engine lookup probe's sudo()-shaped
spelling, instead of a bare { isSystem: true } that spanned every
organization. A name only another organization's catalog carries is refused
with the envelope and message a name that exists nowhere gets. The walled pin
that accepted it is reversed; the by-id pre-image read stays bare, pinned
beside the measurement that the security middleware refuses a foreign row id
and a nowhere id identically first.
Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ
Co-authored-by: Claude <noreply@anthropic.com>
… the writer's catalog The catalog is the writer's organization's positions plus the organization-less ones; a name only another organization carries is refused like any unknown name. The "read across every organization" line is gone. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
The new find in the foreign-row-id pin cast its options to any, growing the query-options-erasure test surface 236 -> 237. The options are on-contract, so they are typed, as assignmentsOf already types the same read. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsDiff read as
② Semver level
③ Boundary flags
Defects (each actionable; both are text-only, and no code change is required):
Implemented-by: VERDICT: FAIL Generated by Claude Code |
…ode supports The changeset, the module docblock and one test's name and comment said every catalog row on a single posture is organization-less. The declared catalog is seeded without an organization, but a position created through the data door by a session with an active organization is stamped with it. The conclusion stands (one organization: the scoped read covers the whole catalog); the premise is reworded. No behaviour or assertion change. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
… conditional on one organization A single-posture deployment holding several organizations still boots (it is reported at error at boot); there each writer reads its active organization's positions plus the organization-less ones. The docblock now says so instead of assuming one organization. Prose only. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsHead facts:
② Semver level
③ Boundary flags
Defects (each actionable):
Implemented-by: VERDICT: FAIL Generated by Claude Code |
…tored as The refusal judged strings only, and its docblock credited the engine with an invalid_type refusal it does not make: a text column's validation reads String(value), and a number, boolean, object or array was stored with 201 (measured over SQLite). Such a value is now judged by its stored text, a scalar by String(value) and an object or array by its JSON, and refused reference_not_found like any name no catalog row carries. The by-id unchanged-value comparison reads stored text, so 123 echoed over a stored '123' stays unjudged. The id-hint pin now asserts the envelope code, status, field and field code. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
…form, not its stored text The docblock and two test names said a non-string is judged by the text it is stored as. The code judges String(value) for a scalar and the JSON text for an object or array; the stored text is the driver's (SQLite stores 123 as '123.0'). The prose now says string form and names that gap. No code or assertion change. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsHead facts:
② Semver level
③ Boundary flags
Defects (each actionable):
Nits (no verdict effect):
Implemented-by: VERDICT: FAIL Generated by Claude Code |
…k a placeholder-shaped name up literally Measured on 0e5f4c7: an operator object ({ $in: [...] }) already answered the engine's invalid_type, but only because the catalog read of its JSON text threw FILTER_TOKEN_UNKNOWN (a fully-wrapped {...} comparand is resolved as a filter placeholder) and the refusal failed open. The same fail-open let { a: 1 }, { $foo: 1 } and '{nope_tok}' be stored unchecked with 201. judgedName now stands down on an operator object, mirroring the engine's #5922 predicate from the same spec inputs (isPlainRecord, ALL_OPERATORS, the retired operators), so invalid_type stays the one answer. A name that classifyFilterToken reads as a placeholder is compared literally against the catalog names sharing its first character, so it is judged, never resolved or failed open; the id hint skips such names. Claude-Session: https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsHead facts:
② Semver level
③ Boundary flags
Nits (no verdict effect):
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #16712
Clause-②: yes
Fixes #20297
Executes the maintainer-confirmed ruling on #16712 (batch #87, option A): a
sys_user_positionwrite whosepositionnames nosys_positionrow at all is refused, instead of answering 201 over an assignment that resolves to nothing. Dispatch: PM sessionsession_01TEah6PeJGjxJfbHaySJjLQ, claim comment 5858098043, branchclaude/issue-16712-position-catalog-refusal.What changes
A new engine middleware,
packages/plugins/plugin-security/src/position-catalog-refusal.ts, registered bySecurityPlugin.start()right AFTER its security middleware (so it runs INSIDE it, after the delegated-admin gate and the CRUD check, the same placement the ADR-0094 permission-set data door uses).active: false) is still a catalog row, so an assignment naming it is accepted and, as before, grants nothing (ADR-0049 shape E). The catalog is the WRITER's: it is read under{ ...context, isSystem: true }(the engine lookup probe's spelling), so the reach is the writer's organization plus organization-less positions. A name only another organization carries is refused exactly like a name no organization carries ([Decision] #16712's position-name refusal reads the position catalog of every organization: scope it to the writer's organization, or keep the ruled literal reading #20297; see the patch-round section below).400 VALIDATION_FAILED, onefields[]entry per offending value:field: 'position',code: 'reference_not_found',constraint: { target: 'sys_position', targetField: 'name' }. This is the envelope the row's sibling lookup columns (user_id,organization_id, ...) already answer with. No new error code:VALIDATION_FAILEDis a registered ADR-0112 code (built withvalidationFailurefrom@objectstack/types, which both HTTP doors map to 400), andreference_not_foundis a member of the closed ADR-0114 field catalog. Nothing inpackages/specchanges.position, and a predicate update (multi: true) that sets it.positionthat is not a string is judged too, by its string form: a number, bigint or boolean byString(value), an object or array by its JSON text. The engine'stextvalidation stores every one of these with 201, except an operator object (next bullet).nulland a blank string (required), a value whoseString()form is longer than the column (max_length), and an operator object. An operator object is a plain object with a declared filter operator as an own key, such as{ $in: [...] }; the engine refuses it asinvalid_type(写入载荷里的算子对象:text型字段不做类型校验,{ title: { $in: [...] } }原样写进库(number型会响亮拒绝) #5922).positionback unchanged, is not judged, so an existing row whose name has left the catalog stays editable. The comparison reads string forms, so123echoed over a stored'123'counts as unchanged.isSystemwrite, the same stand-down the engine's own lookup check (assertReferencesResolve) takes. Measured reason for the seed door: on a fresh single-posture bootAppPlugin.start()writesstack.datainline (packages/runtime/src/app-plugin.ts, theseedLoader.loadbranch), while the declared position catalog is seeded onkernel:ready(bootstrapDeclaredPositions,security-plugin.ts), so a refusal at the seed door would fail every authored assignment seed on the first boot and pass it on the second. Also not judged: invitation acceptance (invitation-placement.tsapply, system context) and the platform bootstraps. This was a code reading, not a live boot of a seed-carrying stack.warn) when the catalog cannot be read, the engine lookup probe's stance.Two consequences outside
plugin-security/src, both required by derived gates:content/docs/permissions/system-context.mdx: new row 23b for the refusal'sisSystemstand-down, and the census counts regenerated withnode scripts/check-system-context-census.mjs --fix(105 to 106 sites).check:system-context-censusrequires an anchor for every elevation read site..changeset/16712-position-catalog-refusal.md:@objectstack/plugin-securityminor, a**BREAKING**banner, and the ADR-0087 dispositionnot-required (no-migration-prescription).check:adr-0087-registrationreads it as[BREAKING+bang] not-required (no-migration-prescription). The level follows the WHICH LEVEL rule (decision batch [WIP] Add query enhancements and advanced validation features #35):Clause-②: yestakes at leastminor, and breaking-ness is carried by the banner, not the level.The two pre-enumerations (the ruling's gate), in-repo half, tree
4d7e740d3bThe out-of-repo halves were measured EMPTY on the card (hotcrm
2f7b2326, hotclm14c899fc, triage comment 5857658468).Precondition 2 (a catalog-less or membership-derived name written into
sys_user_position.position): EMPTY. Every non-test writer:packages/plugins/plugin-security/src/invitation-placement.tsapplyisSystem, on invitation acceptanceintent.positions, the names the issuer put on the invitation (gate-checked at issuance). No literal.examples/app-showcase/src/security/seed-approval-demo.tsassignPositionsisSystem, onkernel:bootstrapped(after the catalog bootstrap)manager,finance,legal,exec,auditor. All five are declared inexamples/app-showcase/src/security/positions.tsallPositions.packages/verify/src/rls.tsprovisionRlsPositionPersonaisSystem, post-bootdeclaredPositionNames(config), the stack's own declared namesinsert/create/upsert/update/*Many) namingsys_user_positionreturned 11 hits, all in*.test.ts. The non-test write calls in every file naming the object add the three rows above.sys_user_permission_setwriter in non-test code.org_member,everyoneand the other membership-derived or anchor names are resolver PROJECTIONS (resolve-authz-context.ts, themapMembershipRoleloop and the impliciteveryonepush), never stored rows. Aposition:value spelling any of the eight anchor or built-in names has 0 non-test hits, and 12 in tests (control).Precondition 1 (in-repo half), re-confirmed on today's tree: EMPTY. No shipped
stack.dataseed carriessys_user_position. The onlyobject: 'sys_user_position'in non-test code is the gate dry-run literal ininvitation-placement.ts. Seed files for other objects are found by the same search (control).Evidence (first round, head
cbdd0e70; the patch-round section below is the evidence for the current head)Live HTTP, real composition (fresh showcase,
pnpm dev -- --fresh -pon a random port, account created throughPOST /api/v1/auth/admin/create-user; the server was stopped by its recorded process group):POST /data/sys_user_positionwithposition= theauditorrow's idVALIDATION_FAILED,fields[0]=position/reference_not_found, message says to writeauditorposition: 'totally_not_a_position_zzz'VALIDATION_FAILED/reference_not_found, generic remedyposition: 'auditor'showcase_inquiryPATCHthat row,position= the auditor idPATCHthat row,reasonedited,positionechoed unchangedPOST /data/sys_user_position/createMany,[finance, fin4nce_typo]fin4nce_typo; nothing storedUnit and integration (
position-catalog-refusal.test.ts: 14 cases atcbdd0e70; each patch-round section below gives the count for its head): a realObjectQLover SQLite, with the realSecurityPluginregistered the way a kernel composition registers it. Every refusal pin assertscodeandstatusthroughresolveThrownHttpError, never a bare throw.sys_user_permission_setgrant of the same set to the SAME account then reads 3 rows.organization_admin-shaped caller (wildcardmodifyAllRecordsplus an explicit per-table deny), get403 PERMISSION_DENIEDfor a bogus name and for a real name alike.cbdd0e70, under the literal predicate. Since [Decision] #16712's position-name refusal reads the position catalog of every organization: scope it to the writer's organization, or keep the ruled literal reading #20297 it is REFUSED, and that pin is reversed (patch round below);org_awriter stampingorganization_id: 'org_b'gets403at the tenant wall, identically for all three names. Measured at the engine layer with theisolatedposture, not over HTTP.Ablations (each through
scripts/ablation-replace.mjsin WRAP mode, plus a shelltraprestoring the absolute path fromHEAD; every restore proven by blob hash ==HEADand an emptygit diff HEAD). The subject resolves through relative imports tosrc/, so no rebuild ordist/preflight applies:expected 'VALIDATION_FAILED' to be 'PERMISSION_DENIED') and the foreign-org wall. The first attempt was a no-op: the tool refused it because the replacement re-contained the anchor. It is reported, not counted.positionjudged toocbdd0e70; the patch round removes that filter, and the hint read is scoped instead, see C1 below)qa_b_only)Local verification at head
cbdd0e70:pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 141 files / 2910 tests passed.pnpm --filter @objectstack/plugin-security typecheck: exit 0. The test layer compiles, with 0 debt.node scripts/pm/dispatch-gates.mjs --commandsover the actual diff: 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0 (--ranreconciliation green). This includescheck:dual-build-cjs-loads,check:i18nandcheck:type-check-debtafter a full workspace build.--print-config): the three changed.tsfiles are in it, and the.mdxand.mdfiles are not.eslint --no-inline-config --format jsoncounted 3 files, 0 errors and 0 warnings.parserOptions.projectis set anywhere (type-aware linting is not enabled), so this diff cannot move a verdict on an untouched file.packages/qa/dogfoodsuites. The three that writesys_user_positionover HTTP (delegation-of-duty,showcase-permission-zoo,delegated-admin-invite) all write catalog names.The predicate's tenancy scope: decided on #20297 (B)
The first round applied the ruled predicate literally and read the catalog unscoped. On a walled posture that accepted a name only another organization carries, and it raised the scope as an open question.
{ ...context, isSystem: true }([finding] a lookup's existence check reads under a bare system context with no tenant — an org-bound caller can store a reference to another organization's row, and tell "exists elsewhere" from "missing" #19808, [finding] the delegated-admin gate still resolvessys_positionby NAME across organizations in two authority decisions — the sibling half #19775 did not repair #19819, [finding] the delegated-admin gate answers "you already hold this position" from ANOTHER organization'ssys_user_positionrow — self-delegation rule 4 reads holdings by (user, position NAME) under a bare system context #19860).sys_user_positionrow whosepositionnames nosys_positioncatalog row — 201 with nothing resolvable (RE-CUT: the originally reported resolution-path defect is disproved, see the 2026-09-09 measurement) #16712 ruling itself is unchanged: a deactivated position is still a catalog row.Acceptance notes
security/explainnaming an unresolvable entry (hotclm evidence, 5611199715): out of scope by dispatch. Noted, not filed.packages/lint/src/validate-security-posture.tslintssys_user_positionseed rows but does not check thatpositionnames a declared position. It is the natural carrier for the seed door this refusal stands down on. Out of scope by dispatch; noted, not filed.assertIssuabledry-runs only the delegated-admin gate, and acceptance writes under a system context. An invitation naming a catalog-less position is still accepted at issuance, and the placement lands silently at acceptance. This is a door this change does not cover. Carrier: whoever next touchesinvitation-placement.ts; no carrier is named today.packages/spec, another lane's.ObjectQL.validate) runs no middleware, so it does not report this refusal, the same as it does not report the engine's lookup check today.plugin-security/srcplus the changeset. The dispatch's file-surface clause namespackages/plugins/plugin-security/src/as the landing, so the middleware registration insecurity-plugin.ts(oneregisterMiddlewarecall, with its import and comment) sits inside it, away from thewriteCheckPoliciesdocblock. Thesystem-context.mdxrow is the one addition outside it, owed tocheck:system-context-census.Seat's append (domain:services #6021,
session_01TEah6PeJGjxJfbHaySJjLQ): the #20297 dev's patch-round section, verbatim from its report 5860074760. The seat also replaced theHeld-for:line in the head with the closing line for that card.Patch round (#20297)
Executes the triage routing on #20297 (comment 5859496956), option B: the predicate the #16712 ruling fixed now reads the WRITER's catalog, meaning the writer's organization plus organization-less rows, in the engine lookup probe's spelling
{ ...context, isSystem: true }(assertReferencesResolve, #19808). The ruling is not re-opened: a deactivated position is still a catalog row, so shape E stays accepted. Dispatch: PM sessionsession_01TEah6PeJGjxJfbHaySJjLQ, claim 5859557977.What changed (head
cb1d5be9)namesWithoutCatalogRow(deps, names, context)andidSpellingHints(deps, values, context)now take the writer's context as a required parameter. They readsys_positionundercatalogReadContext(context), which is{ ...context, isSystem: true }, andassertPositionNamesCatalogRowpassesopCtx.context.security-plugin.tsis unchanged in this round.{ isSystem: true }. It is measured unreachable for a foreign row id, and that is pinned (below).sys_positionby NAME across organizations in two authority decisions — the sibling half #19775 did not repair #19819, [finding] the delegated-admin gate answers "you already hold this position" from ANOTHER organization'ssys_user_positionrow — self-delegation rule 4 reads holdings by (user, position NAME) under a bare system context #19860). "Where it runs" no longer says authorization-first is what protects an unscoped read. Authorization-first itself stays (P1 below).minor,**BREAKING**,!,Clause-②: yesand the ADR-0087 disposition are kept;check:adr-0087-registrationreads[BREAKING+bang] not-required (no-migration-prescription).system-context.mdxrow 23b now describes the scoped read.origin/main7b1e4a48is merged in (merge commit2f036209). The only conflict was the census file-count row.pnpm gen:system-context-censusthen re-derived symbols 88 → 89 (75804ad6). The PR now readsmergeable_state: clean.Mechanism readings. These use the walled two-organization fixture: a real
ObjectQLover SQLite, with the realSecurityPlugin. The "before" column was measured at75804ad6, whose refusal module is byte-identical tocbdd0e70's; the "after" column is the pins atcb1d5be9.org_aadminposition: 'qa_b_only'(onlyorg_bcarries it)VALIDATION_FAILED/reference_not_found, message identical to the exists-nowhere oneposition: 'nope_position'(no organization carries it)org_brow, unknown namePERMISSION_DENIEDPERMISSION_DENIED, the same messagesys_positionby name under{ ...orgAdminContext, isSystem: true }findsqa_b_only0 times andqa_a_ownonce. Under a bare context, or a context with no tenant, each is found once.403("no active organization") first, for an insert and for a predicate update. So the "reads every organization" behaviour is pinned directly onnamesWithoutCatalogRow.singleposture. The fixture seeds its catalog the way asingledeployment seeds its declared catalog, with no organization, so both rows read a nullorganization_id. That is not true of every row on asingledeployment: a position created through the data door by a session with an active organization is stamped with it. On a deployment holding one organization, the scoped read (organization_id = tenant OR organization_id IS NULL) still covers the whole catalog. Asingledeployment holding more than one organization still boots;TenancyServicereports that state aterror([finding] Asingle-posture deployment holding more than onesys_organizationrow boots silently — ADR-0131 §1.2(3) calls that precondition 「a refused boot」 and it is not; the harm surfaces five cards away (platform admin reads 0 rows on/data, system writes refused by #8844) #17010). In that state each writer reads its active organization's positions plus the organization-less ones. The existing single-posture tests carry no tenant, so they never exercise the organization-less term. A new pin writes withtenantId: 'org_a':qa_auditoris accepted and an unknown name is refused.Tests (
position-catalog-refusal.test.ts, 14 → 18 cases)400 VALIDATION_FAILED,reference_not_foundatposition. Its message is byte-identical topositionNotInCatalogMessage('qa_b_only'), and its envelope matches the exists-nowhere case key for key. A predicate update that sets that name is refused the same way.isSystemstand-down, 403-first, the foreign-organization wall and the id hint.Ablations (at
cb1d5be9). Each ran throughscripts/ablation-replace.mjsin WRAP mode plus a shell trap, and every restore was proven by blob ==HEADand an emptygit diff HEAD. The subject resolves through relativesrc/imports, so no rebuild ordist/preflight applies.SYSTEM_CTXSYSTEM_CTX(post-filter already removed)qa_b_onlynope_positionanswers 400 instead of 403), and the foreign-organization wallC1 doubles as the post-filter measurement. With the scoped read and no post-filter, the full suite is green, and the hint pin fails only when the read itself is unscoped. So the post-filter is not kept.
Local verification at
cb1d5be9pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 141 files / 2914 tests passed.pnpm --filter @objectstack/plugin-security typecheck: exit 0. The refusal test is in thetsconfig.test.jsonprogram (checked with--listFiles).node scripts/pm/dispatch-gates.mjs --commandsover the actual diff:--ranreconciliation: "94 run, 0 NOT-MEASURED (a DERIVED zero)";check:query-options-erasure, back to 236 sites: the pre-image pin's read had first been cast toany, andcb1d5be9types it.check-issue-citations.mjs(6 citations, 6 resolve).eslint --print-configputs the three.tsfiles in the population and the.md/.mdxfiles outside it;eslint --no-inline-config --format jsoncounted 3 files, 0 errors and 0 warnings;eslint.config.mjssets noparserOptions.project, so this diff cannot change a verdict on an untouched file.Acceptance notes added this round
organizationId(notenantId) is not tenant-scoped by the engine's driver options. It reads every organization here (measured on the fixture) and, by the same driver-options reading, in the engine's own lookup probe (code reading). It cannot reach this refusal on the isolated posture, where it gets 403 first. Noted, not filed.groupposture the scoped read follows the engine's membership union, so a name from another organization the writer belongs to is accepted. This is a code reading, not measured. Noted, not filed.Seat's append: the round-4 section, from the #20297 dev's report 5860889632, with "stored text" corrected to "string form" where the SQLite measurement shows the two differ (
123is stored as'123.0').Patch round 4 (contract re-review 5860712690)
Measured first. On head
ebf00fd8, before any code change, I ran non-system inserts and by-id updates as an admin, on both the single and the walled fixture, with the same result on each:positionwritten123'123.0'true'1.0'{}'{}'['x']'["x"]'[]'[]'NaNNULL10n'10'123ortrue'123.0'/'1.0''',' 'ornullVALIDATION_FAILED,requiredVALIDATION_FAILED,max_lengthSo the reviewer's premise held: the engine refuses only
null, blank strings and over-long values, and stores everything else. Branch (a) applies.What changed (head
6e1ef6aa)judgedName(value)inposition-catalog-refusal.tsnow judges every value except those the engine answers itself.String(value).{}and['x']in exactly that form). It is never judged byString(['x']), which reads'x'and would accept an array naming a real position that then resolves nothing.null, a blank string, and any value whoseString()form is longer than the column (itsmax_lengthcheck readsString(value)). Patch round 6 below adds the one refusal this missed: an operator object,invalid_type(写入载荷里的算子对象:text型字段不做类型校验,{ title: { $in: [...] } }原样写进库(number型会响亮拒绝) #5922).400 VALIDATION_FAILED,reference_not_foundatposition, with the value's string form asvalueand in the message.system-context.mdxare unchanged; their "Which writes" and "Such a write is now refused" text is true as written.Tests (21 cases, up from 18). Every refusal pin asserts
codeandstatus.123,true,{}and['qa_auditor']are each refused, withvalueequal to'123','true','{}'and'["qa_auditor"]', and nothing is stored.123andtrueare refused, and the stored row is untouched.123over a stored'123'is not judged.VALIDATION_FAILED/ 400, plusfieldandcodeonfields[0], for both refusals.Ablations (at
6e1ef6aa, throughscripts/ablation-replace.mjsin WRAP mode plus a shell trap; every restore was proven by blob ==HEADand an emptygit diff HEAD):123is refused as'123')String(){}is judged as'[object Object]')Verification at
6e1ef6aadispatch-gates --commands: 94 derived, 94 run, all exit 0. The--ranreconciliation reports a derived zero.check:query-options-erasureholds at 236, since no newfindwas added.check:adr-0087-registrationreads[BREAKING+bang] not-required (no-migration-prescription).Acceptance note. On SQLite a non-string is stored as the driver's text (
'123.0','1.0'), not asString(value). So a non-string whoseString()form happens to equal a catalog name, for exampletruewhere a position is namedtrue, is accepted and stored in a form that resolves nothing.sys_position.namehas no pattern that rules such names out. The root cause is the engine'stextleniency for non-string input, which is outside this card; it is measured at the engine layer only.Seat's append: the round-6 section, verbatim from the #20297 dev's report.
Patch round 6 (contract review 3, 5861153477)
Measured first on head
0e5f4c79, on the single fixture, with the refusal in place and then with it ablated (ablation-replaceWRAP; the restore was proven by blob ==HEAD):position{ $in: ['x'] },{ $in: [], a: 1 },{ $regex: 'x' },{ $or: [] }invalid_typeinvalid_type{ $in: ['x'] }invalid_type, row untouchedinvalid_type{ a: 1 },{ $foo: 1 }'{nope_tok}''{current_user_id}','{today}'reference_not_found{},[{ $in: 1 }]reference_not_foundThe reviewer's predicted outcome (
reference_not_foundpre-empting the engine) did not occur, but its cause is real.invalid_typecame through only because the refusal failed open.where. A fully-wrapped{…}comparand is a filter placeholder (classifyFilterTokenin@objectstack/spec, applied by@objectstack/core'sresolveFilterTokens).FILTER_TOKEN_UNKNOWN. The refusal then logged "catalog could not be read" and let the write through.What changed (head
c282e608,position-catalog-refusal.tsonly):judgedNamestands down on them usingisFilterOperatorObject, a narrow mirror of the engine's module-privatefilterOperatorKeysInbuilt from the same spec inputs (isPlainRecord, noDate, an own key inALL_OPERATORSor the retired operators). It is not a$-prefix test:{ $foo: 1 }is judged.catalogCarrieshandles every nameclassifyFilterTokenwould treat as a placeholder: it reads the catalog names that share its first character ($startsWith) and compares in code, under the same scoped context. Such a name is therefore judged, never resolved and never failed open.required,max_length, andinvalid_typefor an operator object. ThestringFormdoc now says a bigint isString(value)and only null, undefined, a symbol, a function or an unserialisable object givesundefined. "Fails open" says a placeholder-shaped name never falls open.Tests (24 cases, up from 21). Every refusal pin asserts
codeandstatus.{ $in: ['x'] }or{ $in: [], a: 1 }gets the engine'sVALIDATION_FAILED/ 400 withfields[0]{ field: 'position', code: 'invalid_type' }, and nothing is stored.{ a: 1 }and{ $foo: 1 }are refusedreference_not_found, with value'{"a":1}'/'{"$foo":1}', and no "could not be read" warning is logged.'{nope_tok}'and'{current_user_id}'are refusedreference_not_foundas themselves. A catalog row really named'{lit_pos}'is found, and the assignment naming it is accepted.Ablations at
c282e608; every restore was proven by blob ==HEADand an emptygit diff HEAD:{"$in":["x"]}answeredreference_not_foundinstead ofinvalid_type{ a: 1 }and'{nope_tok}'were accepted ("expected the write to be refused, but it succeeded")Verification at
c282e608dispatch-gates --commands: 94 derived, 94 run, all exit 0. The--ranreconciliation reports a derived zero.check:query-options-erasureholds at 236.Generated by Claude Code