Skip to content

fix(plugin-security)!: refuse a sys_user_position write whose position names no sys_position row (#16712) - #20292

Merged
objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-16712-position-catalog-refusal
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-16712-position-catalog-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #16712
Clause-②: yes
Fixes #20297

Executes the maintainer-confirmed ruling on #16712 (batch #87, option A): a sys_user_position write whose position names no sys_position row at all is refused, instead of answering 201 over an assignment that resolves to nothing. Dispatch: PM session session_01TEah6PeJGjxJfbHaySJjLQ, claim comment 5858098043, branch claude/issue-16712-position-catalog-refusal.

What changes

A new engine middleware, packages/plugins/plugin-security/src/position-catalog-refusal.ts, registered by SecurityPlugin.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).

  • Predicate: exactly "no catalog row carries this name". A deactivated position (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).
  • Envelope: 400 VALIDATION_FAILED, one fields[] 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_FAILED is a registered ADR-0112 code (built with validationFailure from @objectstack/types, which both HTTP doors map to 400), and reference_not_found is a member of the closed ADR-0114 field catalog. Nothing in packages/spec changes.
  • Message: names the value, says the column takes the catalog NAME, and names the fix. When the value is the record id of a position the writer's own organization can see (the hotclm id-spelling trap), it names that position and says to write its name.
  • Writes judged: every non-system insert (one row or a batch; the batch is refused whole), a non-system update by id that CHANGES position, and a predicate update (multi: true) that sets it.
    • A position that is not a string is judged too, by its string form: a number, bigint or boolean by String(value), an object or array by its JSON text. The engine's text validation stores every one of these with 201, except an operator object (next bullet).
    • Left to the engine, and only these: null and a blank string (required), a value whose String() 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 as invalid_type (写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922).
    • An update that edits another column, or echoes the stored position back unchanged, is not judged, so an existing row whose name has left the catalog stays editable. The comparison reads string forms, so 123 echoed over a stored '123' counts as unchanged.
  • Writes NOT judged, deliberately: every isSystem write, the same stand-down the engine's own lookup check (assertReferencesResolve) takes. Measured reason for the seed door: on a fresh single-posture boot AppPlugin.start() writes stack.data inline (packages/runtime/src/app-plugin.ts, the seedLoader.load branch), while the declared position catalog is seeded on kernel: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.ts apply, system context) and the platform bootstraps. This was a code reading, not a live boot of a seed-carrying stack.
  • Fails open (with a 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's isSystem stand-down, and the census counts regenerated with node scripts/check-system-context-census.mjs --fix (105 to 106 sites). check:system-context-census requires an anchor for every elevation read site.
  • .changeset/16712-position-catalog-refusal.md: @objectstack/plugin-security minor, a **BREAKING** banner, and the ADR-0087 disposition not-required (no-migration-prescription). check:adr-0087-registration reads 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-②: yes takes at least minor, and breaking-ness is carried by the banner, not the level.

The two pre-enumerations (the ruling's gate), in-repo half, tree 4d7e740d3b

The out-of-repo halves were measured EMPTY on the card (hotcrm 2f7b2326, hotclm 14c899fc, triage comment 5857658468).

Precondition 2 (a catalog-less or membership-derived name written into sys_user_position.position): EMPTY. Every non-test writer:

writer context values written catalog-less?
packages/plugins/plugin-security/src/invitation-placement.ts apply isSystem, on invitation acceptance intent.positions, the names the issuer put on the invitation (gate-checked at issuance). No literal. no
examples/app-showcase/src/security/seed-approval-demo.ts assignPositions isSystem, on kernel:bootstrapped (after the catalog bootstrap) manager, finance, legal, exec, auditor. All five are declared in examples/app-showcase/src/security/positions.ts allPositions. no
packages/verify/src/rls.ts provisionRlsPositionPersona isSystem, post-boot declaredPositionNames(config), the stack's own declared names no
  • Search: the write verbs (insert/create/upsert/update/*Many) naming sys_user_position returned 11 hits, all in *.test.ts. The non-test write calls in every file naming the object add the three rows above.
  • Positive control: the same verb pattern finds the sys_user_permission_set writer in non-test code.
  • org_member, everyone and the other membership-derived or anchor names are resolver PROJECTIONS (resolve-authz-context.ts, the mapMembershipRole loop and the implicit everyone push), never stored rows. A position: 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.data seed carries sys_user_position. The only object: 'sys_user_position' in non-test code is the gate dry-run literal in invitation-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 -p on a random port, account created through POST /api/v1/auth/admin/create-user; the server was stopped by its recorded process group):

request answer
POST /data/sys_user_position with position = the auditor row's id 400 VALIDATION_FAILED, fields[0] = position / reference_not_found, message says to write auditor
same, position: 'totally_not_a_position_zzz' 400 VALIDATION_FAILED / reference_not_found, generic remedy
same, position: 'auditor' 201
that holder reads showcase_inquiry 200, 3 rows (the name resolves)
PATCH that row, position = the auditor id 400
PATCH that row, reason edited, position echoed unchanged 200
POST /data/sys_user_position/createMany, [finance, fin4nce_typo] 400, names only fin4nce_typo; nothing stored

Unit and integration (position-catalog-refusal.test.ts: 14 cases at cbdd0e70; each patch-round section below gives the count for its head): a real ObjectQL over SQLite, with the real SecurityPlugin registered the way a kernel composition registers it. Every refusal pin asserts code and status through resolveThrownHttpError, never a bare throw.

  • The ruling's control leg: an id-spelled assignment is refused, and the same account reads 0 rows. A direct sys_user_permission_set grant of the same set to the SAME account then reads 3 rows.
  • Both accepted halves: a catalog name resolves (the holder reads 3 rows). A deactivated position's name is accepted and stored, and grants nothing.
  • Authorization first: a plain member, and an organization_admin-shaped caller (wildcard modifyAllRecords plus an explicit per-table deny), get 403 PERMISSION_DENIED for a bogus name and for a real name alike.
  • Walled posture, two organizations:

Ablations (each through scripts/ablation-replace.mjs in WRAP mode, plus a shell trap restoring the absolute path from HEAD; every restore proven by blob hash == HEAD and an empty git diff HEAD). The subject resolves through relative imports to src/, so no rebuild or dist/ preflight applies:

ablation mutation result
A1 the refusal never fires 7 red (every refusal pin: "expected the write to be refused, but it succeeded"), 7 green
A2 the catalog judged at the TOP of the security middleware, before authorization 2 red: authz-first (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.
A3 an unchanged position judged too 1 red (the fossil-edit pin)
A5 the id hint drops its organization filter (at cbdd0e70; the patch round removes that filter, and the hint read is scoped instead, see C1 below) 1 red (the hint named 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 --commands over the actual diff: 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0 (--ran reconciliation green). This includes check:dual-build-cjs-loads, check:i18n and check:type-check-debt after a full workspace build.
  • Lint, narrowed and proven:
    • (1) The population read from eslint's own config (--print-config): the three changed .ts files are in it, and the .mdx and .md files are not.
    • (2) eslint --no-inline-config --format json counted 3 files, 0 errors and 0 warnings.
    • (3) No parserOptions.project is set anywhere (type-aware linting is not enabled), so this diff cannot move a verdict on an untouched file.
  • Declared to CI, not run locally: the packages/qa/dogfood suites. The three that write sys_user_position over 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.

Acceptance notes

  • security/explain naming an unresolvable entry (hotclm evidence, 5611199715): out of scope by dispatch. Noted, not filed.
  • Lint as the authoring-time second carrier: packages/lint/src/validate-security-posture.ts lints sys_user_position seed rows but does not check that position names 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.
  • Invitation issuance is not judged by this refusal: assertIssuable dry-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 touches invitation-placement.ts; no carrier is named today.
  • The refusal message is English only. Localizing it needs a message-catalog key in packages/spec, another lane's.
  • The write preview (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.
  • The claim's surface listed plugin-security/src plus the changeset. The dispatch's file-surface clause names packages/plugins/plugin-security/src/ as the landing, so the middleware registration in security-plugin.ts (one registerMiddleware call, with its import and comment) sits inside it, away from the writeCheckPolicies docblock. The system-context.mdx row is the one addition outside it, owed to check: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 the Held-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 session session_01TEah6PeJGjxJfbHaySJjLQ, claim 5859557977.

What changed (head cb1d5be9)

Mechanism readings. These use the walled two-organization fixture: a real ObjectQL over SQLite, with the real SecurityPlugin. The "before" column was measured at 75804ad6, whose refusal module is byte-identical to cbdd0e70's; the "after" column is the pins at cb1d5be9.

write, from an org_a admin before after
insert position: 'qa_b_only' (only org_b carries it) 201 400 VALIDATION_FAILED / reference_not_found, message identical to the exists-nowhere one
insert position: 'nope_position' (no organization carries it) 400 400
update by id of an org_b row, unknown name 403 PERMISSION_DENIED 403
update by id of an id that exists nowhere, unknown name 403 PERMISSION_DENIED, the same message 403
  • The pre-image read is never reached for a foreign id. The security middleware answers a foreign id and a nonexistent id identically, before the refusal runs, so the pre-image read is left bare.
  • Scoped vs bare lookup by name. Looking up sys_position by name under { ...orgAdminContext, isSystem: true } finds qa_b_only 0 times and qa_a_own once. Under a bare context, or a context with no tenant, each is found once.
  • A writer with no organization never reaches the refusal on the isolated posture. The security middleware answers 403 ("no active organization") first, for an insert and for a predicate update. So the "reads every organization" behaviour is pinned directly on namesWithoutCatalogRow.
  • single posture. The fixture seeds its catalog the way a single deployment seeds its declared catalog, with no organization, so both rows read a null organization_id. That is not true of every row on a single deployment: 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. A single deployment holding more than one organization still boots; TenancyService reports that state at error ([finding] A single-posture deployment holding more than one sys_organization row 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 with tenantId: 'org_a': qa_auditor is accepted and an unknown name is refused.

Tests (position-catalog-refusal.test.ts, 14 → 18 cases)

  • Reversed, not deleted. A name only another organization carries is now refused 400 VALIDATION_FAILED, reference_not_found at position. Its message is byte-identical to positionNotInCatalogMessage('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.
  • New. The writer's own organization's name is accepted (the control). The scoped and unscoped readings are pinned on the function. A foreign row id is pinned to answer like a nonexistent id. An organization-bound writer on the single posture is pinned.
  • Unchanged and green. The control leg, shape E, the batch / multi / echo legs, the isSystem stand-down, 403-first, the foreign-organization wall and the id hint.

Ablations (at cb1d5be9). Each ran through scripts/ablation-replace.mjs in WRAP mode plus a shell trap, and every restore was proven by blob == HEAD and an empty git diff HEAD. The subject resolves through relative src/ imports, so no rebuild or dist/ preflight applies.

ablation mutation result
B1 the catalog name read reverted to the bare SYSTEM_CTX 2 red: the reversed pin ("expected the write to be refused, but it succeeded") and the organization-bound leg of the function pin
C1 the id-hint read reverted to the bare SYSTEM_CTX (post-filter already removed) 1 red: the hint names qa_b_only
P1 the catalog judged at the top of the security middleware, before authorization 3 red: 403-first, the foreign-row-id pin (nope_position answers 400 instead of 403), and the foreign-organization wall

C1 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 cb1d5be9

  • pnpm --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 the tsconfig.test.json program (checked with --listFiles).
  • node scripts/pm/dispatch-gates.mjs --commands over the actual diff:
    • 94 derived, 94 run, all exit 0;
    • --ran reconciliation: "94 run, 0 NOT-MEASURED (a DERIVED zero)";
    • this includes check:query-options-erasure, back to 236 sites: the pre-image pin's read had first been cast to any, and cb1d5be9 types it.
  • Also run: the four artifact-roster gates flagged for this diff, and the diff-scoped check-issue-citations.mjs (6 citations, 6 resolve).
  • Lint, narrowed and proven:
    • eslint --print-config puts the three .ts files in the population and the .md / .mdx files outside it;
    • eslint --no-inline-config --format json counted 3 files, 0 errors and 0 warnings;
    • eslint.config.mjs sets no parserOptions.project, so this diff cannot change a verdict on an untouched file.

Acceptance notes added this round

  • A context carrying only organizationId (no tenantId) 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.
  • Under the group posture 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 (123 is 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:

position written answer stored as
123 201 '123.0'
true 201 '1.0'
{} 201 '{}'
['x'] 201 '["x"]'
[] 201 '[]'
NaN 201 NULL
10n 201 '10'
by-id update to 123 or true 200 '123.0' / '1.0'
'', ' ' or null 400 VALIDATION_FAILED, required nothing
a 101-character string 400 VALIDATION_FAILED, max_length nothing

So 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) in position-catalog-refusal.ts now judges every value except those the engine answers itself.
  • The refusal is the usual one: 400 VALIDATION_FAILED, reference_not_found at position, with the value's string form as value and in the message.
  • The by-id unchanged-value check compares string forms.
  • The docblock's stand-down sentence now names only the refusals the engine really makes.
  • The changeset and system-context.mdx are 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 code and status.

  • Insert: 123, true, {} and ['qa_auditor'] are each refused, with value equal to '123', 'true', '{}' and '["qa_auditor"]', and nothing is stored.
  • By-id update: 123 and true are refused, and the stored row is untouched.
  • Echo: 123 over a stored '123' is not judged.
  • Id hint: now also asserts VALIDATION_FAILED / 400, plus field and code on fields[0], for both refusals.

Ablations (at 6e1ef6aa, through scripts/ablation-replace.mjs in WRAP mode plus a shell trap; every restore was proven by blob == HEAD and an empty git diff HEAD):

ablation mutation result
N1 only strings are judged 2 red: the insert and update pins ("expected the write to be refused, but it succeeded")
N2 the echo compares raw values 1 red: the echo pin (123 is refused as '123')
N3 no JSON form, so objects fall back to String() 1 red: the insert pin ({} is judged as '[object Object]')

Verification at 6e1ef6aa

  • plugin-security: 141 files / 2917 tests passed; typecheck exit 0.
  • dispatch-gates --commands: 94 derived, 94 run, all exit 0. The --ran reconciliation reports a derived zero.
  • check:query-options-erasure holds at 236, since no new find was added.
  • check:adr-0087-registration reads [BREAKING+bang] not-required (no-migration-prescription).
  • The diff-scoped issue-citation check: 7 citations, all resolve.

Acceptance note. On SQLite a non-string is stored as the driver's text ('123.0', '1.0'), not as String(value). So a non-string whose String() form happens to equal a catalog name, for example true where a position is named true, is accepted and stored in a form that resolves nothing. sys_position.name has no pattern that rules such names out. The root cause is the engine's text leniency 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-replace WRAP; the restore was proven by blob == HEAD):

position with the refusal engine alone
{ $in: ['x'] }, { $in: [], a: 1 }, { $regex: 'x' }, { $or: [] } 400 invalid_type 400 invalid_type
by-id update to { $in: ['x'] } 400 invalid_type, row untouched 400 invalid_type
{ a: 1 }, { $foo: 1 } 201, stored 201
'{nope_tok}' 201, stored 201
'{current_user_id}', '{today}' 400 reference_not_found 201
{}, [{ $in: 1 }] 400 reference_not_found 201

The reviewer's predicted outcome (reference_not_found pre-empting the engine) did not occur, but its cause is real. invalid_type came through only because the refusal failed open.

  • The mechanism. The catalog read put the value's text in where. A fully-wrapped {…} comparand is a filter placeholder (classifyFilterToken in @objectstack/spec, applied by @objectstack/core's resolveFilterTokens).
    • An unknown one throws FILTER_TOKEN_UNKNOWN. The refusal then logged "catalog could not be read" and let the write through.
    • A known one is replaced by its value, so the lookup judged a different name.
  • The consequence. Every object without a declared operator but with at least one key, and every brace-wrapped string, bypassed the refusal.

What changed (head c282e608, position-catalog-refusal.ts only):

  • Operator objects are left to the engine. judgedName stands down on them using isFilterOperatorObject, a narrow mirror of the engine's module-private filterOperatorKeysIn built from the same spec inputs (isPlainRecord, no Date, an own key in ALL_OPERATORS or the retired operators). It is not a $-prefix test: { $foo: 1 } is judged.
  • Placeholder-shaped names are looked up literally. catalogCarries handles every name classifyFilterToken would 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.
  • The id hint skips placeholder-shaped values.
  • Docblock. The stand-down bullet now names the engine's three refusals exactly: required, max_length, and invalid_type for an operator object. The stringForm doc now says a bigint is String(value) and only null, undefined, a symbol, a function or an unserialisable object gives undefined. "Fails open" says a placeholder-shaped name never falls open.
  • The changeset is unchanged; its "Which writes" is still true as written.

Tests (24 cases, up from 21). Every refusal pin asserts code and status.

  • An insert of { $in: ['x'] } or { $in: [], a: 1 } gets the engine's VALIDATION_FAILED / 400 with fields[0] { field: 'position', code: 'invalid_type' }, and nothing is stored.
  • { a: 1 } and { $foo: 1 } are refused reference_not_found, with value '{"a":1}' / '{"$foo":1}', and no "could not be read" warning is logged.
  • '{nope_tok}' and '{current_user_id}' are refused reference_not_found as 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 == HEAD and an empty git diff HEAD:

ablation mutation result
O1 operator stand-down removed 1 red: {"$in":["x"]} answered reference_not_found instead of invalid_type
O2 literal lookup removed 2 red: { a: 1 } and '{nope_tok}' were accepted ("expected the write to be refused, but it succeeded")
B1 / C1 / P1 / N1 / N2 / N3 re-run as before 2 / 1 / 3 / 3 / 1 / 2 red

Verification at c282e608

  • plugin-security: 141 files / 2917 → 2920 tests passed; typecheck exit 0.
  • dispatch-gates --commands: 94 derived, 94 run, all exit 0. The --ran reconciliation reports a derived zero.
  • check:query-options-erasure holds at 236.
  • The diff-scoped issue-citation check: 11 citations, all resolve.

Generated by Claude Code

… 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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 26 documentable anchor(s).

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via reference_not_found (literal, a string literal in a comment on a changed line; a string literal in positionNotInCatalogError))
  • content/docs/automation/approvals.mdx (via sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/data-modeling/fields.mdx (via reference_not_found (literal, a string literal in a comment on a changed line; a string literal in positionNotInCatalogError))
  • content/docs/data-modeling/objects.mdx (via sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/deployment/environment-variables.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT), sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/permissions/administrator-guide.mdx (via sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/permissions/authentication.mdx (via sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/permissions/authorization.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT), sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/permissions/delegated-administration.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT), sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/permissions/permission-sets.mdx (via sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/permissions/positions.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT), sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/permissions/system-context.mdx (via assertPositionNamesCatalogRow (symbol, a top-level function), reference_not_found (literal, a string literal in a comment on a changed line; a string literal in positionNotInCatalogError), sys_position (literal, a string literal in POSITION_CATALOG_OBJECT), sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/permissions/tenant-audit-census.mdx (via SYSTEM_CTX (symbol, a top-level const object))
  • content/docs/protocol/backward-compatibility.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))

⛔ 9 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/index.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))
  • content/docs/releases/v13.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT), sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/releases/v14.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT), sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/releases/v15.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT), sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/releases/v16.mdx (via sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/releases/v17/17-0.mdx (via SYSTEM_CTX (symbol, a top-level const object), sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))
  • content/docs/releases/v17/17-1.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))
  • content/docs/releases/v17/17-2.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))
  • content/docs/releases/v17/17-4.mdx (via sys_user_position (literal, a string literal in POSITION_ASSIGNMENT_OBJECT))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cf3ea1eff1f7d4d1c5c223b6df4e02ad5f2bcd36 — the merge of head c282e60807cd32e36a83a502f0e02b59b75cef95 into base d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat note (domain:services, #6021) · 2026-09-27T19:22Z: CI is green on cbdd0e70b7, and the head now conflicts with main. Both are deliberately left for after #20297 is ruled.

  • CI. Every check name's latest run on cbdd0e70b7 is success (31) or a roster-expected skip (4):
    • Console Pin Gate and Packed-tarball smoke (opt-in);
    • Auto Label and Check PR Size, which ran green on the open event and skip on the later label and body edits.
  • Conflict. mergeable_state: dirty against main 2dccb7d4, in content/docs/permissions/system-context.mdx only. It is the census counts: main gained another isSystem read.
    • The resolution is mechanical: merge origin/main, take either side's counts, then run pnpm gen:system-context-census, which re-derives them from the merged tree (per git merge-tree's own prescription).
  • Why not now. The PR is held for [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 (Held-for: line above, with no auto-merge). The merge rides the next round:
    • under A, the landing preparation;
    • under B or C, the patch round.
    • A round spent now would likely re-conflict on this hot census file before the ruling.

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>
@github-actions github-actions Bot added size/xl and removed size/l labels Sep 27, 2026
… 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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cb1d5be9d86e8c8c208db9f3cbdb17c66afa2b8e
Local-runs: none

① Derived judgments

Diff read as git diff 7b1e4a48…cb1d5be9 (5 files, +1019/−9, same list as the PR's files API). Check-runs on the head: 42 runs, all on cb1d5be9. Latest per name: 31 success and 4 skipped (Auto Label and Check PR Size, both re-run as skipped at 21:44 after the body edit; Console Pin Gate; Packed-tarball smoke). All seven required contexts are success. The gate families the diff derives are carried by green jobs: check:system-context-census, check:query-options-erasure and check:doc-authoring in Lint & Repo Gates, and check-adr-0087-registration in Check Changeset. The seat's ACCEPT 5860106286 reads "33 success / 2 skips"; that predates the 21:44 re-run, and no gate conclusion differs.

  • The accept set narrows on the sys_user_position write path. RIGHT.
    • createPositionCatalogRefusal is registered in SecurityPlugin.start() after the security middleware (security-plugin.ts:1899 vs :3783). The engine chain is FIFO (middlewares.push, index-walking next, engine.ts:4723/4749-4760), so authorization precedes the catalog verdict.
    • Judged: a non-system insert (one row or a batch, refused whole), a by-id update that changes position, and a predicate update that sets it.
    • Not judged: isSystem, an unchanged echo, and non-string / blank / over-length values. The engine's required check uses isMissing, which trims (record-validator.ts:253-254, :626), so the whitespace stand-down leaves no hole.
    • The envelope reuses ADR-0112 VALIDATION_FAILED via validationFailure and ADR-0114 reference_not_found; no packages/spec change.
    • Matches ruling 5582062659: the predicate is exactly "no catalog row carries this name", and shape E is accepted (pinned at test :287).
  • Whose catalog: B, as routed on [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 (5859496956). RIGHT.
    • Both catalog reads run under catalogReadContext = { ...context, isSystem: true } (position-catalog-refusal.ts:150-153, :266, :301).
    • buildDriverOptions forwards tenantId whenever the context carries one and the object is not tenancy-disabled (engine.ts:4996-5011). SqlDriver.applyTenantScope emits (organization_id = :tenant OR organization_id IS NULL), or IN (tenantIds) OR IS NULL under group (sql-driver.ts:14498-14531).
    • So the reach is exactly the writer's organization plus organization-less rows, every organization for a tenant-less context, and the membership union under group. That is identical to the referenceExists spelling in the assertReferencesResolve docblock at the merge base (engine.ts:7396-7410).
    • A foreign-only name and a nowhere name answer the same envelope with a byte-identical message (test :436-473).
  • The bare pre-image read (SYSTEM_CTX, :231). RIGHT, supported.
    • It runs only inside next() of the security middleware. For an update with a single id, step 2.7 re-reads the target under the CALLER's context ANDed with the write filter (security-plugin.ts:2652-2660).
    • Layer 0 puts the tenant wall in that filter on walled postures, so a foreign row and a nonexistent id are both refused 403 record_access_denied with one message (:2665-2700) before the refusal runs. Pinned at test :495-519.
    • Where no write filter exists (an unbounded principal), no foreign organization is reachable except by a platform operator, who reads every organization anyway. So the bare read cannot answer more than the writer already sees.
    • Every door running the middleware chain runs the security middleware first. Its only non-system early exits are the public-form grant (:1949-1987, an insert on the form's own declared object) and the empty-principal fall-open (:2095). The delegated-admin gate runs ahead of the fall-open and fails RBAC tables closed for principal-less contexts (:2081-2083).
  • Public surface unchanged. RIGHT.
    • package.json exports has only . → dist/index.*. src/index.ts does not re-export position-catalog-refusal.ts (control: invitation-placement is exported twice there).
    • The only importer is security-plugin.ts:30, used inside the body of start(), so no type from the module reaches the entry type graph. The package has no api-surface snapshot.
    • Per references/contract-review.md, an unreachable, unaddressable .d.ts is shipped bytes, not the published accept set. The required third parameter on namesWithoutCatalogRow / idSpellingHints is module-internal.
  • Fail-open warn: the RIGHT level. The catch around ql.find logs warn and returns null. The write proceeds and is visible: a functional degradation, and nothing claimed as persisted is lost (AGENTS.md "Degradation log levels"). The log line and the refusal message carry no tracker number.
  • Every refusal case asserts the envelope. RIGHT. All 11 refusal pins read code + status through resolveThrownHttpError (envelopeOf); none is a bare toThrow.
  • Census row 23b and counts. RIGHT.
    • There is one new ExecutionContext.isSystem read, at :360 inside assertPositionNamesCatalogRow (the anchored symbol). Sites 105→106, files 44→45, symbols 88→89, behaviour-bearing 102→103; packages unchanged at 19. The gate is green.
    • Row 23b's seed-order sentence holds. The inline stack.data load is in the AppPlugin.start() block (app-plugin.ts:1405-1406, «the replayer outlives start()», load at :1532), and the declared catalog is seeded by runBootstrap on kernel:ready (security-plugin.ts:4312).
  • WRONG: the PR body contradicts the head's accept set. The first-round section still asserts the pre-patch behaviour, with no head qualifier:
    • «The catalog is read with a bare system context, so a name ANY organization's catalog carries is accepted (see the open question below).» This is false at cb1d5be9.
    • «a name only another organization carries is ACCEPTED (the literal predicate, pinned);» False: the pin is reversed.
    • «A5 | the id hint drops its organization filter | 1 red»: no such filter exists at head.
    • «position-catalog-refusal.test.ts, 14 cases»: the head has 18.
    • The whole «Open question for the card — the predicate's tenancy scope (not decided here)» section has been decided.
    • The patch-round append is accurate, but the body as a whole makes contradictory claims about what the diff publishes, and it becomes the squash commit message.

② Semver level

  • The changeset .changeset/16712-position-catalog-refusal.md carries @objectstack/plugin-security: minor, the **BREAKING** banner, ! in the title, an adr-0087: not-required (no-migration-prescription) HTML-comment marker and Clause-②: yes. Check Changeset, which runs check-adr-0087-registration.mjs --base, is success.
    • The level matches AGENTS.md: yes takes at least minor. The narrowing is BREAKING and is carried by the banner and the disposition; major is refused by check-changeset-no-major.
    • Nothing authorable is renamed or retired, so no FROM→TO migration is owed; the prose still names the fix.
    • Not skip-changeset, which is right: the package publishes a behaviour change.
  • The Clause-②: line is yes, with no arm, on the claim (5858098043, 5859557977), the PR body and the changeset. AGENTS.md allows at most one arm, check-widening-tells.mjs "never blocks a yes", and the ruling set it. Acceptable, RIGHT; a (narrowing) arm would have been more precise, but it is optional.
  • The prose, sentence by sentence against the diff and the code:
    • "the same reach the engine gives its own lookup-reference check" ✓.
    • "A name that only ANOTHER organization's catalog carries is refused exactly like any unknown name, with the same envelope and the same message" ✓ (test :455-461).
    • "A writer whose context names no organization sees every organization's positions" ✓ (hasTenant false → no scope; pinned at test :483-493).
    • "The check runs after authorization" ✓.
    • "the measured in-repo and consumer writers (hotcrm, hotclm) all write catalog names" ✓ per 5857658468. hotclm has no writers, so this is vacuous but not false.
  • WRONG (the premise is overstated; the conclusion is intact): «On a single-organization posture every position is organization-less, so every writer sees the whole catalog.»
    • Declared positions are organization-less there (seedCtx(undefined) → { isSystem: true }, per-organization-catalog.ts:193-195).
    • But a session's activeOrganizationId reaches ctx.tenantId on every posture (resolve-authz-context.ts:403, :417); only the walled-posture membership check at :532-546 ever clears it. buildDriverOptions forwards it for sys_position, which has no tenancy opt-out (sys-position.object.ts:326), and injectTenantOnInsert stamps organization_id from options.tenantId.
    • So a position created through the data door by such a session on a single posture carries an organization. The conclusion survives (with one organization, = tenantId OR IS NULL covers both); the universal premise does not.

③ Boundary flags

  • Dev (1a) falsified, so the pre-image read stays bare. Answered above; supported by security-plugin.ts:2652-2700 and test :495. Accepted.
  • Dev (1c): the hint post-filter is removed. The hint read is under the same scoped context (:301-304), so a foreign row is invisible and no hint can name it; pinned at test :537-549. Accepted.
  • Dev (3): the single-posture organization-bound writer pin is present (test :298-313). It is what makes the "plus organization-less rows" term measured. Accepted.
  • Dev (4): the tenant-less writer is pinned at the function, not the chain. The single / isolated refusal at security-plugin.ts:3329-3410 (ADR-0123 D2) fires for insert and update before next(), so the chain cannot carry one; the function pin at test :483 is the right place. Accepted.
  • Dev acceptance note: an organizationId-only context reads unscoped. Confirmed by buildDriverOptions keying on tenantId alone (engine.ts:4996); the same as the engine probe. No door reaches it on the isolated posture (403 first). Noted, not filed; agreed.
  • Dev acceptance note: the group posture membership union. Confirmed (engine.ts:5027-5031, sql-driver.ts:14511-14519); the same as the engine probe. Noted; agreed.
  • Dev flag (round 1): Clause-②: yes on a narrowing. Answered under ②.
  • Dev flag: check:query-options-erasure 236→237, fixed by typing with no baseline edit. The head's Lint & Repo Gates is success, and the baseline file is not in the diff. Accepted on the check-run.
  • Round-1 open_questions (the scope): ruled B on [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 and executed; verified above. Closed.
  • Round-1 out-of-scope notes (the invitation door under isSystem, the lint second carrier, security/explain, ObjectQL.validate parity): not this diff's, and they stay as acceptance notes. Agreed.
  • Reviewer observation, no defect of this PR (carrier: none; escalated for the seat's judgment). The public-form grant path (security-plugin.ts:1949-1987) returns next() ahead of the delegated-admin gate for an insert on the form's declared object. A FormView deliberately authored over sys_user_position would let an anonymous, tenant-less context reach this refusal, and the insert itself, with the catalog read spanning every organization. This is the pre-existing shape of that door, not introduced here.
  • Reviewer observation: a platform operator holding an active organization in context, and writing another organization's assignment by id, is judged against the operator's own organization's catalog. These are B semantics, the same as the engine probe, and consistent with the routing, which rejected C. No action.
  • Not re-run here: the ablations B1 / C1 / P1 and the local test counts are the dev's claims. The head's Test Core and TypeScript Type Check are success, and the code reading above supports each pin independently.

Defects (each actionable; both are text-only, and no code change is required):

  1. Edit PR fix(plugin-security)!: refuse a sys_user_position write whose position names no sys_position row (#16712) #20292's body so the first-round section no longer asserts the pre-patch accept set for this head. Rewrite, or mark as superseded, the sentences quoted under ①: «…bare system context, so a name ANY organization's catalog carries is accepted…», «…ACCEPTED (the literal predicate, pinned)», the A5 row, «14 cases», and the «Open question…» section. The body must state one accept set for cb1d5be9.
  2. Reword the changeset sentence «On a single-organization posture every position is organization-less, so every writer sees the whole catalog.» to a premise the code supports: for example, that the declared catalog is organization-less and the deployment has one organization, so every writer reads the whole catalog. This is CHANGELOG-shipped prose.

Implemented-by: claude/issue-16712-position-catalog-refusal
Reviewed-by: session_01TEah6PeJGjxJfbHaySJjLQ

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ebf00fd8f7fa14cd581a4754b76a34b9814c2067
Local-runs: none

① Derived judgments

Head facts:

  • Net diff against the merge base 7b1e4a48: 5 files, +1024/−9, the same list the PR files API returns (the changeset, system-context.mdx, position-catalog-refusal.ts, its test and security-plugin.ts).

  • Delta since the FAILed head cb1d5be9 (git diff cb1d5be9d8 refs/pm/pr-20292): 3 files, +13/−8, all prose: changeset lines 44-46, module docblock lines 56-60, and one test title plus its three comment lines (:298-302). No code line and no expect changed, so the code judged at cb1d5be9 is the code judged here.

  • Check-runs on the head: 35 runs, all on ebf00fd8, one per name. 33 are success and 2 skipped (Console Pin Gate and Packed-tarball smoke (opt-in), both roster-expected). All seven required contexts are success, including Check Changeset.

  • The head repo is the base repo, and there is no governed path in the file list.

  • The accept set narrows on the sys_user_position write path. RIGHT as ruled, with one uncovered corner (defect 1).

    • createPositionCatalogRefusal is registered in SecurityPlugin.start() (security-plugin.ts:3783, inside start() :1264-4568) after the security middleware (:1899). The engine chain is FIFO (engine.ts:4723, :4749-4761), so the delegated-admin gate (:2080) and the CRUD check run first. The sibling ADR-0094 door at :3810 has the same placement.
    • Judged (position-catalog-refusal.ts:200-242): every non-system insert (one row or a batch, refused whole), a by-id update whose position differs from the stored pre-image, and a multi update that sets it.
    • The engine middleware vocabulary is insert / update / delete / find / findOne / count / aggregate, with no upsert operation, so no unlisted write verb bypasses it.
    • The predicate is exactly "no catalog row carries this name": the read is where name with no active filter (:274), and shape E is pinned at test :287-296.
    • The envelope: validationFailure builds VALIDATION_FAILED / 400 (types/src/validation-failure.ts:45-48, :76), and reference_not_found is a member of FieldErrorCode (spec/src/api/errors.zod.ts:281, :290). No packages/spec change. The refusal text and the warn line carry no tracker number.
  • Whose catalog: B, as routed (5859496956). RIGHT.

    • Both catalog reads run under catalogReadContext, the spread-context-plus-isSystem read (:153-156, :269, :304).
    • buildDriverOptions forwards tenantId whenever the context carries one and the object is neither tenancy-disabled nor federated (engine.ts:4996-5018), and adds tenantIds under group (:5027-5031). SqlDriver.applyTenantScope emits equality-or-NULL, or IN-or-NULL (sql-driver.ts:14511-14531).
    • The spelling is identical to assertReferencesResolve / referenceExists (engine.ts:7396-7410, :7524-7537).
    • Foreign-only and nowhere names answer one envelope, with a byte-identical message (test :437-474).
  • The bare pre-image read (SYSTEM_CTX, :142, :234). RIGHT, supported. It runs only inside the security middleware's next(). Step 2.7 re-reads a by-id target under the CALLER's context ANDed with the write filter (security-plugin.ts:2652-2664), and refuses a hidden or absent row as record_access_denied with one message (:2665-2700). Pinned at test :496-520.

  • The isSystem stand-down. RIGHT (ruled; premise verified).

    • The inline seed runs in AppPlugin.start() (app-plugin.ts:636 opens start, the next member is at :1774, the load at :1514-1532). SeedLoaderService writes under isSystem (metadata-protocol/src/seed-loader.ts:1620, :1763, :2737).
    • The declared catalog is seeded by runBootstrap on kernel:ready (security-plugin.ts:3896-3898, :4312). The write-triggered middleware at :4397-4404 re-runs only when bootstrapRanOnce.
    • Row 23b's seed-order sentence holds.
  • The blank and over-length stand-downs. RIGHT. isMissing trims (record-validator.ts:253-254). required fires on insert (:626-627), and required_cleared fires on an explicit clear in an update (:1288-1295). max_length binds for text (:696-699).

  • The non-string stand-down. WRONG (defect 1).

    • isJudgedValue (:191-193) admits only strings, and the docblock (:80-82) says the engine answers a non-string with invalid_type. For a text field, the validator does not.
    • A number or boolean is not missing (:626-629) and is not an operator object (:660-680; filterOperatorKeysIn returns nothing for a scalar, :469-472). It is outside the ADR-0104 shape branch (:960-966, which covers reference, file and structured-JSON types only), and the string branch coerces it with String(value) for the length checks (:697) and refuses nothing else.
    • A grep for "expected a string" / "must be a string" / "is not a string" over packages/objectql/src returns zero hits (control: the same grep shape finds max_length at :699). Every typeof value site (:511, :697, :763, :852-873) coerces or serves another type. The delegated-admin gate coerces too (delegated-admin-gate.ts:256, :747).
    • So a non-system insert with position = the JSON number 123 (or true) is judged by neither layer and lands as '123' with a 201, naming no row: the silent-201 class the ruling refuses.
    • This is established at the engine write seam by code reading. The REST protocol layer's own body handling was not traced; a flow create_record or an SDK write reaches the engine seam directly.
    • The changeset's «Such a write is now refused» and the PR body's «Writes judged: every non-system insert» are overstated by this corner.
  • Public surface unchanged. RIGHT.

    • package.json exports has only . → dist/index.*. src/index.ts (218 lines) does not re-export the module (control: invitation-placement.js is re-exported at :52 and :56).
    • The sole importer is security-plugin.ts:30, used in the body of start(), and the package has no api-surface snapshot.
    • The required third parameter on namesWithoutCatalogRow / idSpellingHints is module-internal.
  • Fail-open at warn: the RIGHT level. The catch around the catalog find logs warn and returns null (:275-283). The write proceeds visibly, and nothing claimed as persisted is lost. Note: an unregistered sys_position returns null silently (:267). That is unreachable in a real composition, since SecurityPlugin registers that object itself.

  • Census row 23b and counts. RIGHT. There is one new ExecutionContext.isSystem read at :363, inside the anchored assertPositionNamesCatalogRow. Sites 105→106, reads 111→112, behaviour-bearing 102→103, files 44→45, symbols 88→89; packages unchanged at 19. Lint & Repo Gates is green, and the row text's tenant-scope sentence matches the code.

  • Tests: 18 cases; the envelope discipline is overstated (defect 2). Sixteen refusals assert code + status through resolveThrownHttpError. The id-hint case (:538-550) captures two refusals and asserts only fields[0].message, with no code and no status. The PR body («Every refusal pin asserts code and status … never a bare throw») and the test docblock (:13-17) claim otherwise.

  • The pre-enumerations re-run on the head tree: EMPTY.

    • Catalog-less names as position: values in non-test packages/** and examples/**: 0 hits (control in tests: 8 hits across 4 files).
    • object: 'sys_user_position' in non-test .ts: 1 hit (invitation-placement.ts:165, the gate dry-run); control object: 'sys_permission_set': 5 files.
    • The three non-test writers the PR body names are as described: invitation-placement.ts (SYSTEM_CTX :39, write :199); seed-approval-demo.ts (SYS :58, names :69 / :358, all in the allPositions of positions.ts :115); and provisionRlsPositionPersona in verify/src/rls.ts (:538-575, isSystem :556).
  • Prior record 5860245907, defect 1 (the PR body contradicted the head's accept set): CURED.

  • Prior record defect 2 (the universal single-posture premise): CURED.

    • Changeset :44-47 now reads «On a single-organization deployment the declared positions carry no organization and any other position can only carry the one organization there is, so every writer sees the whole catalog».
    • Verified: seedCtx() carries no organization on single (per-organization-catalog.ts:193-194). A session's activeOrganizationId reaches ctx.tenantId on every posture (resolve-authz-context.ts:403, :417) and is cleared only under a wall (:532-547), so a data-door position on single is stamped with the one organization. The scoped read's OR-NULL arm covers both.
    • The module docblock :56-61 adds the several-organizations case correctly: TenancyService counts sys_organization and reports at error while boot proceeds (tenancy-service.ts:69-75, :491-501; ADR-0131 §1.2 item 3, :132-139).
    • The test title and comment at :298-302 are no longer universal.
  • PR body sweep, the remaining sentences.

② Semver level

  • The changeset .changeset/16712-position-catalog-refusal.md carries @objectstack/plugin-security: minor, ! in the title, the **BREAKING** banner, Clause-②: yes, and the adr-0087 HTML-comment marker not-required (no-migration-prescription), which is a member of CATEGORIES (scripts/check-adr-0087-registration.mjs:494-500). Check Changeset is success. RIGHT:
    • yes takes at least minor (AGENTS.md Post-Task Implement ObjectStack protocol specification with Zod schemas and TypeScript interfaces #3).
    • major is refused by the launch-window guard (check-changeset-no-major.mjs), so the banner plus the disposition is the only available spelling of breaking-ness.
    • Nothing authorable is renamed or retired, so no FROM→TO mapping is owed and the category fits.
    • The package publishes a behaviour change, so skip-changeset would be wrong.
  • The Clause-②: line is yes with no arm, identical on the claims (5858098043, 5859557977), PR body line 2 and the changeset. Valid: AGENTS.md allows at most one arm, the ruling set yes, and check-widening-tells.mjs never blocks a yes. A (narrowing) arm would be the more precise spelling; optional, not a defect.
  • The prose, sentence by sentence. The envelope, the 403-first sentence, the "Whose catalog" paragraph, and the shape-E, stored-rows and system-context bullets all match the code (cited in ①).
    • Two sentences are overstated by defect 1: «Such a write is now refused» and «Which writes. Every non-system insert … every non-system update by id that CHANGES position, and every predicate update». They are true for string values only.
    • «the measured in-repo and consumer writers (hotcrm, hotclm) all write catalog names» is vacuous for hotclm (zero writers, 5857658468), but not false.

③ Boundary flags

  • Dev (1a) falsified, so the pre-image read stays bare: accepted; security-plugin.ts:2652-2700 and test :496-520 support it.
  • Dev (1c), the hint post-filter removed: accepted. The hint read is scoped (:304-307) and pinned at :538-550; see defect 2 for that pin's assertions.
  • Dev (2), the context threaded as a parameter, with no module-level value and security-plugin.ts untouched this round: accepted (:257-261, :296-300, :366-368).
  • Dev (3), the single-posture organization-bound writer pin (:298-314) and the reworded premise: accepted. This is what makes the OR-NULL arm measured.
  • Dev (4), the tenant-less writer pinned at the function (:484-494), not the chain: accepted. ADR-0123 D2 refuses a userId-bearing write with no active organization before next() (security-plugin.ts:3328-3410), so the chain cannot carry one.
  • Dev acceptance note, an organizationId-only context reads unscoped: confirmed. buildDriverOptions keys on execCtx.tenantId alone (engine.ts:4996-4999), the same as the engine probe, and no door reaches it on the isolated posture. Agreed, not filed.
  • Dev acceptance note, the group posture membership union: confirmed (engine.ts:5027-5031, sql-driver.ts:14511-14518); the same as the engine probe. Agreed.
  • Dev flag, Clause-②: yes on a narrowing: answered under ②.
  • Dev flag, check:query-options-erasure 236→237→236, fixed by typing with no baseline edit: Lint & Repo Gates is success on the head, and no baseline file is in the diff. Accepted.
  • Dev deviation, the branch not re-synced with origin/main: not a contract matter; the seat re-reads mergeability before the queue.
  • open_questions: empty on all three patch-round reports. Round 1's scope question was ruled B on [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 and executed (verified in ①). Closed.
  • Round-1 out-of-scope notes (the lint second carrier, security/explain, ObjectQL.validate parity): they stay as acceptance notes and are not this diff's. Agreed.
  • Escalated to the seat, no defect of this PR: the invitation door. assertIssuable dry-runs only the gate, and afterAcceptInvitation writes under SYSTEM_CTX (invitation-placement.ts:39, :199). So an invitation naming a catalog-less position lands silently at acceptance, and the PR names no carrier. It is the ruling's own class on a first-class operator path, and under Prime Directive chore: version packages #10 a contract violation whose evidence is the code path is filable. Recommend a card.
  • Reviewer observation, pre-existing, no action: the public-form grant returns next() ahead of the delegated-admin gate for an insert on the form's declared object (security-plugin.ts:1950-1988). A FormView authored over sys_user_position would reach this refusal and the insert with a tenant-less context. Not introduced here.
  • Reviewer observation, no action: a platform operator holding an active organization who writes another organization's assignment by id is judged against the operator's own catalog. These are B semantics as routed; C was rejected.
  • Not re-run here: ablations B1 / C1 / P1 and the local counts are the dev's claims. The head's Test Core and TypeScript Type Check are success, and the code reading above supports each pin's sensitivity.

Defects (each actionable):

  1. Close or narrow the non-string corner. isJudgedValue (position-catalog-refusal.ts:191-193) admits only strings, and the docblock :80-82 credits the engine with an invalid_type refusal it does not make for text (record-validator.ts:697 coerces; :960-966 covers other types). Either:
    • (a) judge every non-null scalar by its string form, so 123 is looked up as '123' and refused reference_not_found like any unknown name, and add one pin asserting code + status for a numeric and a boolean position, on insert and on a by-id update; or
    • (b) keep the stand-down; rewrite the docblock sentence to say that non-string scalars are not judged here and are stored by the engine; narrow the changeset's «Such a write is now refused» / «Every non-system insert…» and the PR body's «Writes judged» accordingly; and file the engine's text-accepts-a-number leniency as its own card.
      (a) is the smaller change, and the one the ruling's predicate implies.
  2. Assert the envelope in the id-hint case. In test :538-550, both refusals go through envelopeOf but assert only fields[0].message. Add expect([env.code, env.status]).toEqual(['VALIDATION_FAILED', 400]) for each, plus field and code on fields[0], so that the PR body's «Every refusal pin asserts code and status» and the test docblock :13-17 are true.

Implemented-by: claude/issue-16712-position-catalog-refusal
Reviewed-by: session_01TEah6PeJGjxJfbHaySJjLQ

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0e5f4c7955f8284f618bcc49d6d4b5caf54b205d
Local-runs: none

① Derived judgments

Head facts:

  • Net diff against the merge base 7b1e4a48 (git diff 7b1e4a48 refs/pm/pr-20292): 5 files, +1144/−9, the same list the PR files API returns (the changeset, system-context.mdx, position-catalog-refusal.ts, its test, security-plugin.ts). The head repo is the base repo; no governed path; 1153 changed lines; draft; mergeable_state: clean at this read.

  • Delta since the FAILed head ebf00fd8: 2 files, +132/−12.

    • 6e1ef6aa is code: stringForm (:209-233), judgedName (:243-254) and the echo compare (:303), plus three new cases and four new assert lines in the test.
    • 0e5f4c79 is prose: the docblock and two test names.
    • The code judged at ebf00fd8 outside those lines is unchanged.
  • Check-runs on the head: 42 runs, 35 names, all on 0e5f4c79.

    • Latest per name: 33 success and 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in), both roster-expected). All seven required contexts are success.
    • Lint & Repo Gates carries check:system-context-census and check:query-options-erasure; Check Changeset carries check-adr-0087-registration.
  • The accept set narrows on the sys_user_position write path. RIGHT as ruled (5582062659).

    • createPositionCatalogRefusal is registered at security-plugin.ts:3783, inside start() (:1264-4568), after the security middleware at :1899. The engine chain is FIFO (engine.ts:4722-4761), so the delegated-admin gate and the CRUD check run first.
    • Judged (:261-306): every non-system insert, one row or a batch. insertMany routes through insert with the partial-row flag (engine.ts:12884-12886), so it meets the same middleware and is refused whole there too. Also judged: a by-id update whose value's string form differs from the pre-image's, and a multi update that sets position. Dispatch is read through resolveEngineUpdateDispatch, the engine's own predicate.
    • The predicate is exactly "no catalog row carries this name": the read is where name with no active filter (:337), and shape E is pinned (test :287-296).
    • The envelope: validationFailure builds VALIDATION_FAILED on a ValidationError (types/src/validation-failure.ts:76-82), and both doors map it to 400. reference_not_found is typed to FieldErrorCode. No packages/spec change, and no tracker number in the refusal text or the warn line.
  • Whose catalog: B, as routed (5859496956). RIGHT.

    • Both catalog reads run under catalogReadContext (:164-167), the spread-context-plus-isSystem read, at :333 and :368.
    • buildDriverOptions forwards tenantId when the context carries one (engine.ts:4996-5011), and tenantIds under group (:5027-5031). SqlDriver.applyTenantScope emits equality-or-NULL, or IN-or-NULL (sql-driver.ts:14494-14531). This is identical to referenceExists (engine.ts:7524-7537).
    • A foreign-only name and a nowhere name answer one envelope with a byte-identical message (test :489-526). The writer's own name is accepted (:528-534), and the tenant-less reading is pinned on the function (:536-546).
  • The bare pre-image read (SYSTEM_CTX, :153, :296-303). RIGHT, supported. It runs only inside the security middleware's next(), and a foreign row id and a nonexistent id are refused identically before it (pinned at :548-572).

  • The new non-string path. RIGHT for the values it names, with one WRONG derived judgment (defect 1).

    • Left to the engine, and really answered by it:
      • null / undefined (judgedName :244): the engine's required on insert (record-validator.ts:626-627) and required_cleared on update (:1288-1295).
      • A blank string (:245): isMissing trims (:253-254).
      • A value whose String() form exceeds 100 (:252): the bounded-string branch reads String(value) for every type (:697-699), so a 101-digit bigint or a long array is the engine's max_length, not a hole.
    • Judged: a number, bigint or boolean by String(value); a plain object or array by its JSON text; a non-serialisable object falls back to String(value).
      • For these the engine's text branch coerces and refuses nothing (:697), and the write lands. The dev's SQLite measurement (123 → '123.0', true → '1.0') agrees with the code.
      • Pinned: an insert of 123, true, an empty object and a one-element array holding qa_auditor (test :385-404); a by-id update to 123 / true (:406-417); and 123 echoed over a stored '123', not judged (:419-425).
    • WRONG: the operator-keyed object.
      • The docblock :85-87 says «Nothing else is the engine's: its text validation reads String(value) and refuses no number, boolean, object or array», and the PR body says «The engine's text validation refuses none of these and stores them with 201».
      • For a plain object whose own keys include a declared filter operator ($in, $eq, …), the engine DOES refuse on a text field. valueMayBeAnObject is false for text (record-validator.ts:486-488), filterOperatorKeysIn returns the keys (:469-472), and the 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 branch answers invalid_type: "is a filter operator, not a value — a filter belongs in the query 'where'" (~:660-680).
      • That validation runs inside the executor (engine.ts:12608 on insert, :13870 / :14181 on update), AFTER this middleware. So at the head such a value is judged here first, as its JSON text, and refused reference_not_found «no position is named …». The engine's more precise refusal on this column is shadowed.
      • The status (400) and VALIDATION_FAILED are the same, but the field-level code and the remedy change for an already-refused shape on a published write path. That is an undeclared, unpinned change of the refusal dialect (Prime Directive Add comprehensive test suite for Zod schema validation #12: one dialect). It is reachable through the REST body like any JSON object.
    • The echo compare (:303) cannot refuse a value the store would resolve.
      • It refuses only a value whose string form names no catalog row, and the engine stores such a value in a form that resolves nothing.
      • For example, a padded ' qa_auditor ' is stored padded: there is no write-path trim in engine.ts or the validator beyond emptiness, and the resolver joins by exact name.
      • The inverse driver-text divergence is theoretical: a value whose String() form is not a catalog name while its SQLite text is (e.g. 1 against a position named '1.0'). Observation only.
  • Public surface unchanged. RIGHT.

    • package.json exports has only . → dist/index.*.
    • src/index.ts is untouched (empty diff) and does not re-export the module (control: invitation-placement.js is re-exported at :52 / :56).
    • The only importer is security-plugin.ts:30, used in the body of start(), and there is no api-surface snapshot.
  • Fail-open at warn: the RIGHT level. The catch around the catalog find logs warn and returns null (:338-348); the write proceeds visibly.

  • Census row 23b and counts. RIGHT.

    • There is one new ExecutionContext.isSystem read at :427, inside the anchored assertPositionNamesCatalogRow. Sites 105→106, reads 111→112, behaviour-bearing 102→103, files 44→45, symbols 88→89; packages 19.
    • The row's tenant-scope sentence matches the code. The seed-order sentence holds: app-plugin.ts:1532 is inside start(), and security-plugin.ts:4298 hooks runBootstrap on kernel:ready.
  • Every refusal case asserts the ADR-0112 envelope. RIGHT now. Of the 21 it cases, every refusal pin (the id-hint case included, :595-596, :603-604) reads code + status through resolveThrownHttpError; there is no bare toThrow.

  • Prior record 5860245907, defect 1 (the body contradicted the head's accept set): the tenancy sentences stay cured, but a NEW instance exists (defect 2). Body line 58 says «position-catalog-refusal.test.ts: 14 cases at cbdd0e70, 18 at the current head». The head has 21, and the round-4 section says «21 cases, up from 18». The body states two counts for one head.

  • Prior record 5860245907, defect 2 (the universal single-posture premise): CURED (changeset :44-49, docblock :56-61, test :298-302; all conditional on one organization).

  • Prior record 5860712690, defect 1 (the non-string corner): CURED by 6e1ef6aa, branch (a), widened to objects and arrays and pinned as above. The docblock's invalid_type claim is gone; its replacement sentence carries the residual error in defect 1.

  • Prior record 5860712690, defect 2 (the id-hint envelope): CURED (:595-596, :603-604).

  • PR body sweep, the remaining sentences. These all hold:

    • the rewritten "Writes judged" bullet;
    • the echo sentence;
    • «The changeset and system-context.mdx are unchanged; … true as written»;
    • the round-4 table, labelled as measured at ebf00fd8;
    • the acceptance note on driver text;
    • the seat's "stored text" → "string form" correction. There are 0 remaining "stored text" hits outside the seat's own note, and the table's «stored as» column is the measured one.
      «the three-line registration in security-plugin.ts» is four statements in a 14-line hunk (a nit, carried).

② Semver level

  • The changeset carries @objectstack/plugin-security: minor, ! in the title, the **BREAKING** banner, Clause-②: yes, and the adr-0087 HTML-comment marker not-required (no-migration-prescription). Check Changeset is success. RIGHT:
    • yes takes at least minor.
    • major is refused by scripts/check-changeset-no-major.mjs (the launch-window guard, header lines 5-6), so the banner plus the disposition is the available spelling.
    • Nothing authorable is renamed or retired, so no FROM→TO mapping is owed.
    • The package publishes a behaviour change, so skip-changeset would be wrong.
  • The Clause-②: line is yes with no arm, identical on the claims (5858098043, 5859557977), PR body line 2 and the changeset. Valid; a (narrowing) arm would be more precise, but it is optional.
  • The changeset prose, sentence by sentence:
    • "Whose catalog" ✓ (the code and driver are cited in ①).
    • «A writer whose context names no organization sees every organization's positions» ✓ (hasTenant false → no scope).
    • «Which writes … Every non-system insert … update by id that CHANGES position … predicate update» ✓, now for every JSON type.
    • «Such a write is now refused» ✓.
    • «the same envelope a bad user_id or organization_id on the same row already gets»: the same status, code, field and field-level code. The engine's entry carries a constraint with target only (engine.ts:7486-7494), and this one adds targetField. A superset; nit.
    • «the measured in-repo and consumer writers (hotcrm, hotclm) all write catalog names»: vacuous for hotclm, but not false.
  • The module docblock:
    • "Whose catalog" ✓ and "Which writes it judges" ✓.
    • The stand-down bullet: defect 1.
    • The stringForm doc says «undefined for a value that is no JSON value at all». A bigint is not a JSON value, yet it is judged by String(value); the values that return undefined are symbol and function (nit).
  • Row 23b and the census counts ✓. The PR body: defect 2, plus the "three-line" nit.

③ Boundary flags

  • Dev round 4 (5860889632): branch (a), taken after measuring first; the measurement table and the N1/N2/N3 ablations are the dev's claims. Accepted for the shapes measured; the engine reading (record-validator.ts:697) supports them. The one object shape the dev did not measure is the operator-keyed object (defect 1).
  • Dev round 5 (5861031897): "stored text" is reworded to "string form" in the module and in two test names, and pr_body_append is omitted for the seat to reword the body. The seat did so (body line 169 note), verified in the body. Accepted.
  • Dev: the changeset and system-context.mdx left unchanged as "true as written after (a)". Accepted.
  • Dev out_of_scope_findings (new): Field.text accepts non-string input and stores the driver's text; engine layer only, carrier none, not filed. Escalated to the seat: a JSON body reaches the engine's text field with no coercion step traced on the REST path, so "reach unmeasured" is the only thing between this and a filable reproducible defect (Prime Directive chore: version packages #10). Recommend that the engine lane measure it at a public door and file.
  • Carried acceptance notes: an organizationId-only context reads unscoped (engine.ts:4996-4999), and the group membership union (:5027-5031). The same as the engine probe. Agreed, not filed.
  • Carried, escalated again: the invitation door. assertIssuable dry-runs only the gate, and acceptance writes under a system context. The PR still names no carrier; the prior record recommended a card. Not this diff's defect.
  • Carried: check:query-options-erasure 236→237→236, with no baseline edit. Lint & Repo Gates is success, and no baseline file is in the diff. Accepted.
  • open_questions: empty on every report of both cards. Round 1's scope question was routed B on [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 and is executed (verified in ①). Closed.
  • Reviewer observations, no action:
    • A platform operator holding an active organization is judged against that organization's catalog (B as routed; C was rejected).
    • The public-form grant path reaching this refusal tenant-less is pre-existing.
    • A batch on insertMany is refused whole by this middleware, like every other middleware refusal and unlike the engine's per-row reference_not_found inside the executor. The body declares it as "refused whole", so no defect.
  • Not re-run here: the ablations N1/N2/N3 and B1/C1/P1 and the local counts are the dev's claims. Test Core and TypeScript Type Check are success on the head, and the code reading above supports each pin's sensitivity.

Defects (each actionable):

  1. Close or declare the operator-object corner. judgedName (position-catalog-refusal.ts:243-254) judges a plain object whose own keys are declared filter operators. It does so ahead of the engine's 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 invalid_type refusal, which validateRecord makes for exactly that shape on a text field (record-validator.ts:469-472, :486-488, the 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 branch), because validation runs inside the executor after this middleware (engine.ts:12608, :13870, :14181).
    • Preferred, (a): stand down in judgedName on a non-array plain object that carries a $-prefixed own key, so the engine's refusal stays the one answer. Add a pin asserting VALIDATION_FAILED / 400 with fields[0].code equal to invalid_type for an insert of an object whose own key is $in.
    • Otherwise, (b): keep judging it, and declare it in the docblock, the changeset's "Which writes" and the PR body, with a pin on the reference_not_found envelope for that shape.
    • In either branch, rewrite docblock :85-87 and the PR body sentence «The engine's text validation refuses none of these and stores them with 201» so they no longer assert that the engine refuses no object.
  2. Edit PR fix(plugin-security)!: refuse a sys_user_position write whose position names no sys_position row (#16712) #20292's body line 58: «14 cases at cbdd0e70, 18 at the current head». The head has 21 cases, as the round-4 section already says. The body, which becomes the squash commit message, must state one count for this head.

Nits (no verdict effect):

  • «three-line registration» is four statements in a 14-line hunk.
  • «the same envelope … already gets»: this entry's constraint adds targetField to the engine's target-only one.
  • The stringForm doc puts bigint on the wrong side of "no JSON value at all".

Implemented-by: claude/issue-16712-position-catalog-refusal
Reviewed-by: session_01TEah6PeJGjxJfbHaySJjLQ

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c282e60807cd32e36a83a502f0e02b59b75cef95
Local-runs: none

① Derived judgments

Head facts:

  • Net diff against the merge base 7b1e4a48 (git diff 7b1e4a48 refs/pm/pr-20292; the ref resolves to c282e608): 5 files, +1252/−9, the same list and counts as the PR files API (changeset +65, system-context.mdx +10/−9, the module +509, its test +654, security-plugin.ts +14). The head repo is the base repo; no governed path; 1261 changed lines; draft; auto_merge unset; mergeable_state: clean at this read.

  • Delta since the FAILed head 0e5f4c79: one commit, c282e608, 2 files, +128/−20.

    • The module gains the @objectstack/spec/data import (:141-146), FILTER_OPERATOR_KEYS (:252-255), isFilterOperatorObject (:265-268), one stand-down line in judgedName (:281), catalogCarries (:399-410) replacing the inline read in namesWithoutCatalogRow (:374), the id-hint skip (:431), and docblock edits (:83-95, :134-136, :217-218).
    • The test gains three cases (:419-463).
    • Nothing else in the head moved since 0e5f4c79.
  • Check-runs on the head: 42 runs, 35 names, all on c282e608.

    • Latest per name: 31 success and 4 skipped (Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in)). The first two are advisory, and the last two are roster-expected.
    • All seven required contexts are success, and Check Changeset is success. Lint & Repo Gates carries check:system-context-census, check:query-options-erasure and check:undeclared-dep-imports.
  • The accept set narrows on the sys_user_position write path. RIGHT as ruled (5582062659, confirmed 5582244791).

    • Registration is at security-plugin.ts:3783, inside start(), after the security middleware. The chain is FIFO, so authority is judged first (pinned :487-506).
    • Judged (:297-342): every non-system insert (a batch is refused whole), a by-id update whose string form differs from the pre-image, and a multi update that sets position.
    • The predicate is where name with no active filter, and shape E is accepted (:287-296).
    • The envelope: validationFailure builds ADR-0112 VALIDATION_FAILED, and reference_not_found is typed to FieldErrorCode. No packages/spec change, and no tracker number in the refusal text or the warn line.
  • Whose catalog: B, as routed (5859496956). RIGHT.

    • Every catalog read (both branches of catalogCarries, and the hint read) runs under catalogReadContext(context) (:174-177, passed at :369 / :374 and :427 / :433): the writer's context spread, with isSystem set.
    • Foreign-only and nowhere names answer one envelope with a byte-identical message (:535-572). The own-organization control is accepted (:574-580), and the tenant-less reading is pinned on the function (:582-592).
  • The operator-object stand-down. RIGHT, and the mirror is exact.

    • At the merge base, packages/objectql/src/validation/record-validator.ts builds FILTER_OPERATOR_KEYS from ALL_OPERATORS plus Object.keys(RETIRED_FILTER_OPERATORS) (:456-459). filterOperatorKeysIn returns nothing unless the value is isPlainRecord and not a Date, then filters Object.keys (:469-472). The 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 branch refuses invalid_type when that list is non-empty and valueMayBeAnObject(def) is false (:660-680).
    • The head's isFilterOperatorObject uses the same two spec inputs and the same guard, with some in place of filter (:252-268).
    • The spec's isPlainRecord is a non-null object that is not an array (authoring-key-lint.ts:219-221). ALL_OPERATORS is the 17 field operators plus $and / $or / $not (filter.zod.ts:2987-3008), and the retired set is $regex / $options (:3081).
    • Probed:
      • a Date: both say it is not an operator object; the engine stores it, and the head judges it by its JSON text;
      • an array: isPlainRecord is false in both; the engine stores it, and the head judges it by its JSON text;
      • an operator key that is inherited or non-enumerable: Object.keys excludes it in both; the engine stores it, and the head judges it;
      • $regex / $options: in both sets; the engine refuses, and the head stands down;
      • valueMayBeAnObject is not mirrored; for position it is constant, since the column is Field.text, not multi (sys-user-position.object.ts:84-89).
    • The order agrees too. The engine runs required first (isEmptyForRequired is isMissing for a non-multi field, :253, :270-273), then the 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 branch, then max_length on String(value) (:697-699). The head runs null/undefined, blank, operator object, then the String() length (:278-290).
    • The column declares only required: true, maxLength: 100, with no minLength and no pattern, so the three stand-downs are the column's whole set of engine refusals.
    • No value the engine refuses is judged here, and no value the engine stores is left unjudged.
  • The literal lookup for placeholder-shaped names. RIGHT.

    • Coverage is complete by construction. @objectstack/core's resolveFilterTokens (packages/core/src/utils/filter-tokens.ts:410-441) rewrites exactly the strings for which classifyFilterToken(node) is non-null, walking arrays and object values; hasFilterToken (:393-400) is the same predicate. The engine applies it to every read where through resolveWhereFilterTokens (engine.ts:1105-1110). catalogCarries gates on classifyFilterToken(name) === null (:400), so it takes the equality read for exactly the strings the query layer leaves untouched, and the literal path for exactly the strings it would rewrite or refuse.
    • The prefix read carries no placeholder. The recogniser is a string wholly wrapped in one brace pair, optionally preceded by a dollar sign, with no brace inside (context-tokens.zod.ts:235). So the $startsWith comparand name.charAt(0) is the single character brace or dollar sign, which can never match a recogniser that needs at least three characters. fields: ['name'] is not a comparand.
    • The dollar-sign first character is bound as a plain LIKE literal: the SQL driver compiles $startsWith through applyLike, which escapes the comparand (sql-driver.ts:16303-16304). Every startsWith('$') in the engine, spec and driver is a KEY test, never a value test (a grep over the three files gives 8 hits, all keys; control in driver-memory/filter-refusal.ts:838,872).
    • The unbounded read is the right shape.
      • The engine applies no default limit to find: there is no DEFAULT_LIMIT, MAX_LIMIT or limit ?? in engine.ts or sql-driver.ts, and the only limit: hit in the engine is the bulk-hook budget at :4109.
      • The engine's own seedAutonumber 的播种扫描是「取前 5000 行、不排序、不按前缀过滤」的 MAX —— 超过 5000 行的对象会播种出低于真实 MAX 的号 #6249 passage (engine.ts:6116-6118) documents that the SQL driver's scanMaxNumericTail pushes a prefix LIKE down with NO limit, and that a windowed read is the wrong shape when a missed row is a wrong answer. Here a limit could hide the exact name behind other brace-prefixed names and produce a false refusal.
      • AGENTS.md's only limit rule is about REST query-parameter allowlists, not engine reads. The erasure ratchet baseline is unchanged base-to-head, and Lint & Repo Gates is green.
    • The tenant scope is unchanged: both catalogCarries reads take context: readCtx (:401, :407). Fail-open is unchanged: catalogCarries is awaited inside the try of namesWithoutCatalogRow (:373-383), so a throw on either read logs the same warn and returns null.
    • No legitimate stored name is refused.
      • A non-placeholder name keeps the equality read.
      • A placeholder-shaped name is found by prefix plus an exact compare. This is pinned by the row named with a brace-wrapped lit_pos being found and its assignment accepted (:456-461), with zero "could not be read" warns (:462).
      • applyLike's prefix match returns a superset (SQLite LIKE folds ASCII case), and the in-code === narrows it.
  • The id hint skips placeholder-shaped values (:431). RIGHT. Such a value in where: id would be token-resolved or refused; skipping yields no hint, and the refusal stands.

  • Every refusal case asserts the ADR-0112 envelope. RIGHT. 24 it cases (grep count 24; no it.each / skip / only). Every refusal pin, the three new ones included (:426-427, :438-439, :453-454), reads code + status through resolveThrownHttpError; there is no bare toThrow.

  • Public surface unchanged. RIGHT.

    • package.json exports is only .. src/index.ts is not in the diff and does not re-export the module (control: invitation-placement.js at :52 / :56).
    • The new @objectstack/spec/data VALUE import is covered: @objectstack/spec is in dependencies (workspace:*), and claim-seed-ownership.ts:104 already value-imports from the same subpath (control). check:undeclared-dep-imports rides in the green Lint & Repo Gates.
  • Census row 23b and counts. RIGHT.

    • There is one new ExecutionContext.isSystem read at :489, inside the anchored assertPositionNamesCatalogRow; the isSystem: true at :163 and :176 are object-literal keys.
    • Sites 105 to 106, reads 111 to 112, behaviour-bearing 102 to 103, files 44 to 45, symbols 88 to 89; packages 19, multi-read files 8.
    • Row 23b's tenant sentence matches catalogReadContext. Its seed-order sentence holds: seedLoader.load at app-plugin.ts:1532 is inside start (opened at :636), and runBootstrap is hooked on kernel:ready at security-plugin.ts:4298.
  • Prior record 5860245907: defect 1 (the body contradicting the head) is CURED and still cured, since every first-round claim carries its head label. Defect 2 (the universal single-posture premise) is CURED (changeset :44-47, docblock :56-61, test :298-302).

  • Prior record 5860712690: defect 1 (the non-string corner) is CURED, since every value the engine stores is judged. Defect 2 (the id-hint envelope) is CURED (:641-642, :649-650).

  • Prior record 5861153477:

    • Defect 1 (the operator object) is CURED by branch (a): the stand-down at :281, the pin asserting invalid_type at :419-430, and the docblock :83-95 and the PR-body sentence rewritten, so that neither says the engine refuses no object.
    • Defect 2 (two case counts) is CURED: body line 64 now defers to the per-round sections, round 6 says 24, and the file has 24.
    • Nits: "three-line registration" is now "one registerMiddleware call, with its import and comment"; the bigint sentence is fixed (:217-218); targetField is carried below.
  • The round-6 mechanism claims, tested against the code.

    • At 0e5f4c79, the JSON text of an operator object is placeholder-shaped (no inner brace), so the equality read threw FILTER_TOKEN_UNKNOWN, the catch logged and returned null, and the engine answered invalid_type.
    • An object with keys but no operator, and any brace-wrapped string, took the same fail-open path and were stored. A known token was resolved to a different comparand.
    • An empty object's JSON is not placeholder-shaped (the recogniser needs at least one inner character), and an array's JSON starts with a bracket, so those were judged.
    • The dev's table is consistent with the code at every row.
  • PR body sweep.

    • The body states ONE accept set (the "What changes" section is the head's) and ONE case count for this head (24).
    • The "Writes judged" and "Left to the engine, and only these" bullets match judgedName exactly.
    • The pre-enumeration claim, re-run on the head tree: 0 non-test hits for an anchor or built-in name as a position: value, and 8 hits in tests (control).
    • Ablations O1 / O2 and the re-run counts (N1 now 3, N3 now 2) are the dev's claims; each is consistent with which new pins the mutation would redden.
    • mergeable_state: clean is true at this read.
    • Nits only, listed below.

② Semver level

  • The changeset .changeset/16712-position-catalog-refusal.md carries @objectstack/plugin-security: minor, ! in the title, the **BREAKING** banner, Clause-②: yes, and the adr-0087 HTML-comment marker not-required (no-migration-prescription). Check Changeset is success. RIGHT:
    • yes takes at least minor.
    • major is refused by the launch-window guard, so the banner plus the disposition is the available spelling of breaking-ness.
    • Nothing authorable is renamed or retired, so no FROM-to-TO mapping is owed.
    • The package publishes a behaviour change, so skip-changeset would be wrong.
  • The Clause-②: line is yes with no arm, identical on both claims (5858098043, 5859557977), PR body line 2 and the changeset. Valid: AGENTS.md allows at most one arm, and the ruling set yes.
  • The changeset prose, sentence by sentence:
    • The title and "What changed" describe what this diff newly refuses. The operator object was already the engine's refusal before this PR, so "Such a write is now refused" with reference_not_found is true of what changed.
    • "Which writes" names the three write shapes correctly. The value-level stand-downs (required, max_length, invalid_type) are the engine's own, and every such write is still refused, by the engine.
    • "Whose catalog", the single-organization sentence, the tenant-less sentence, "What did not change" and "Who is affected" all match the code cited in ①.
    • The one carried nit: "the same envelope a bad user_id ... already gets"; this entry's constraint adds targetField to the engine's target-only one.
  • The module docblock: "Whose catalog", "Which writes it judges", the stand-down bullet and the stringForm doc are now exact. Two wording nits below.
  • Row 23b and the census counts: exact.

③ Boundary flags

  • Dev round 6 (5861368311), branch (a), with the reviewer's premise corrected by measurement. Accepted: my code reading in ① reproduces the fail-open mechanism the dev measured, and the fix closes both the operator-object dialect question and the placeholder bypass.
  • Dev deviation, "beyond the directive": the placeholder fail-open was a live bypass, fixed in the same file with an unbounded prefix read. Accepted as the correct shape (① above): no engine default limit exists, the engine's own prefix scan is unbounded for the same reason, and a limit would reintroduce a miss.
  • Dev deviation: the predicate is mirrored, not imported. Accepted. filterOperatorKeysIn is module-private, @objectstack/objectql is only a devDependency of this package, and the mirror is built from the two spec exports the validator itself calls the blessed inputs. The pins at :419-430 and :432-445 hold the mirror to the engine in both directions, for a declared operator and for an undeclared dollar-prefixed key.
  • Dev deviation: valueMayBeAnObject not mirrored. Accepted; it is constant for this column (① above).
  • Dev: the changeset left unchanged. Accepted (② above).
  • Dev open_questions: empty on every report of both cards. Round 1's scope question was routed B and is executed. Closed.
  • Dev out_of_scope_findings (three, carried):
    • an organizationId-only context reads unscoped, and cannot reach the refusal on the isolated posture;
    • the group posture membership union;
    • Field.text accepting non-string input and storing the driver's text.
      All three are the engine's, not this diff's. The third stays escalated to the seat as the prior record left it: recommend that the engine lane measure it at a public door and file.
  • Carried, escalated again: the invitation door. assertIssuable dry-runs only the gate, and acceptance writes under a system context; the PR still names no carrier. Not this diff's defect; the prior records recommended a card, and this one does too.
  • New reviewer observation, no defect of this PR, escalated for the seat's judgment (carrier: none).
    • The head correctly accepts an assignment naming a catalog row whose name is placeholder-shaped, because such a row exists.
    • Downstream, the resolver looks positions up with $in over the holder's names (resolve-authz-context.ts:917 at the merge base). That where passes through resolveFilterTokens, so a brace-wrapped catalog name is either rewritten to a different comparand or refused as an unknown token at grant resolution.
    • sys_position.name carries no pattern excluding that shape. This is pre-existing, on the catalog's own write path, and not introduced here.
  • Reviewer observation, no action: on a case-insensitive collation (the MySQL default), the equality read matches a case variant while the literal path's === does not. So a case variant of a brace-prefixed name is refused where a case variant of a plain name is accepted. The stored name itself is accepted on every driver, and the refusal names the exact spelling to write.
  • Carried observations, no action: a platform operator holding an active organization is judged against that organization's catalog (B as routed); the public-form grant path reaching this refusal tenant-less is pre-existing; a batch is refused whole by this middleware, as declared.
  • Not re-run here: ablations O1 / O2, the B1 / C1 / P1 / N1 / N2 / N3 re-runs, and the local counts are the dev's claims. Test Core and TypeScript Type Check are success on the head, and the code reading above supports each new pin's sensitivity.

Nits (no verdict effect):

  • PR body, round 6: "What changed (head c282e608, position-catalog-refusal.ts only)". The commit also adds 46 test lines, which the next subsection describes.
  • PR body, round 6, the Docblock bullet: "only null, undefined, a symbol, a function or an unserialisable object gives undefined". The docblock's own clause is "neither serialised nor stringified", and a circular plain object falls back to String(value), not undefined.
  • Module docblock :134-136: "so its verdict never falls open". A throw on the prefix read itself still falls open at warn, as the same paragraph says; the sentence means that the placeholder shape is not a failed read.
  • Docblock :391 and body line 249 say "a fully-wrapped brace pair"; the recogniser also admits a leading dollar sign, which the code handles through classifyFilterToken.
  • Changeset :27-28: this entry's constraint adds targetField to the engine's target-only one (carried).

Implemented-by: claude/issue-16712-position-catalog-refusal
Reviewed-by: session_01TEah6PeJGjxJfbHaySJjLQ

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 01:07
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit f39ea95 Sep 28, 2026
47 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-16712-position-catalog-refusal branch September 28, 2026 01:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment