Skip to content

feat(objectql,rest)!: refuse a new assignment to a banned user; count and hand over their work - #30546

Draft
objectstack-fleet[bot] wants to merge 15 commits into
mainfrom
claude/project-thread-dvfr9q
Draft

objectstack-fleet[bot] wants to merge 15 commits into
mainfrom
claude/project-thread-dvfr9q

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#3 · os-bill · session_01Utp6CZavYYLh5k5r6jhgCF

Requested by Bill · project thread

Fixes #30205

Clause-②: no (narrowing) — a non-system create or update that sets a user field to a user whose ban is in force is refused 400 VALIDATION_FAILED / reference_user_banned, a write that answered 201 / 200 before. A value the row already stores is not judged.
Metadata-protocol: none

维护者速读

  • 改了什么:新记录不能再指派给已封禁的用户(写入返回 400,字段码 reference_user_banned);新增两条接口,按对象统计某用户名下的记录,并可一次性把它们转给一位在用的用户。
  • 为什么改:封禁只会让人下线,他名下的工单、客户还挂着,新的工作也还能派给他,管理员没有办法交接。
  • 风险与回滚:这是一次收窄(BREAKING):以前能成功的「指派给封禁用户」现在被拒。已经存着该用户的记录照常可编辑,系统写入(种子、安装、恢复)不受影响。回滚就是 revert 本 PR,没有数据迁移。
  • 先看:packages/objectql/src/engine-referential-integrity.ts(拒绝逻辑,读封禁状态的方式与存在性探测一致)、packages/core/src/security/user-assigned-work.ts(计数与交接的规则)、packages/rest/src/user-work-routes.ts(两条路由)。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/engine-banned-user-reference.test.ts,应看到 9 个测试全部通过。
  • 需要你做的:这是 BREAKING 收窄,落地需要你本人点头。

What changes

1. A new assignment to a banned user is refused. The write-time reference check (assertReferencesResolve) now also asks, for every caller-supplied value that resolves to sys_user, whether that user's ban is in force (banned is true and ban_expires is empty or still ahead), and refuses it with the field code reference_user_banned beside reference_not_found. It is one extra find on sys_user per write ($in for several ids), read with the same elevated context the existence probe uses, so a caller who cannot read the user object is refused the same way. The #29062 rule carries over unchanged: a value every row of the write already stores is not judged, so an edit form that carries the whole record back still saves. A system write keeps its exemption; a flow's runAs: 'system' keeps the check, as it does for reference_not_found. If the ban-state read itself fails, the write is not refused for a ban and a warning is logged.

2. Count a user's work. GET /api/v1/security/users/:id/assigned-work (client.security.assignedWork.count) answers, per object and user field, how many records hold the user, counted under the caller's own context. The owner field, user fields and lookups to sys_user count; platform tables (sys_*), managedBy objects, disabled packages and readonly / system fields such as created_by do not. Objects the caller cannot read are named in unreadable; at most 200 fields are counted (truncated).

3. Hand it over. POST /api/v1/security/users/:id/assigned-work/transfer (client.security.assignedWork.transfer) with { toUserId, objects? } moves up to 500 records per call, each as an ordinary update under the caller's context, so sharing, validation, approval locks and hooks all apply. It answers per field what moved, what failed (row id, code, and the sentence the /data door answers for the same throw, at most 10 per field) and how many remain. A banned, unknown or identical toUserId is refused before any write. Multi-valued user fields are counted but not transferred. Neither route touches the ban: the ban stays POST /api/v1/auth/admin/ban-user, urgent and alone.

The new FieldErrorCode member

@objectstack/spec's FieldErrorCode is a closed response vocabulary, and this PR adds one member, reference_user_banned, with its message template in en, zh-CN, ja-JP and es-ES (validation-message.ts). It is not an authorable key: no schema an author writes gains, loses or reshapes anything, which is why the changeset carries the ADR-0087 not-required (no-migration-prescription) marker. A client that switches on the closed set gains one case; the regenerated content/docs/references/api/errors.mdx lists it.

Where the code lives, and the one engine.ts touch

  • The count and hand-over rules live in @objectstack/core (security/user-assigned-work.ts), not in a new objectql module as first planned: @objectstack/rest has objectql only as a dev dependency, so a shared module both can import at runtime has to sit in core. objectql and the hand-over both ask core's security/user-ban.ts (fix(core)!: a banned user's credentials are refused while the ban holds, at every door #30287), so the ban predicate has one owner.
  • packages/objectql/src/engine.ts changes only in the reference-check host literal (4 lines): it now hands find and logger to assertReferencesResolve for the ban-state read. Nothing else in engine.ts moves.

Census rows the new routes need

  • The two routes answer through the shared sendOk / sendError and are declared to check:route-envelope at zero.
  • The new REST family user-work arrives classified in the authorization conformance matrix: row anonymous-deny-user-work (both handlers open with enforceAuth), with its booted proof in showcase-anonymous-deny-surfaces.dogfood.test.ts (anonymous 401 on both routes; a member's count served 200, and a member's hand-over reaching its own unknown-target 404 before any write). The matrix docblock's family figure moves 18 → 19.
  • The registrar file joins REST_SERVER_REGISTRAR_FILES, the docblock floor (6), the canonical-query table (0) and the objectQLProvider consumer census (6 sites). The two routes are sdk rows in the route ledger.
  • The transfer is a no-record-echo row in the internal-fields tripwire and an ANSWERS_THE_SENTENCE row in the hook-refusal ledger: a sandboxed hook refusing a moved row reads, in failures[].message, as the hook's own sentence, and every fault shape §6 drives keeps its words withheld.
  • user-assigned-work.ts is classified not-a-served-read in core's second-object read-exposure pin: the ban-state read answers only a yes/no about the target, never a row.

Tests

  • packages/objectql/src/engine-banned-user-reference.test.ts (9): insert refused and nothing written; the read is elevated (the caller cannot read sys_user and is refused all the same); a user field and a future expiry; a multi-valued field refused for its banned member only; active and expired-ban users allowed; update refused; a stored value stays editable; a system write is exempt; a missing user stays reference_not_found.
  • packages/core/src/security/user-assigned-work.test.ts (14): the ban predicate, field picking, counts under the caller's context, unreadable, disabled packages, the hand-over, per-row failures, the row cap and remaining, the three refusals, the elevated target read, and the per-door failure wording.
  • packages/rest/src/user-work-routes.test.ts (7) and packages/client/src/admin-surfaces.test.ts (+2).
  • The new engine doubles open update with assertEngineUpdateDispatch, refuse a logical operator rather than read it as a field, and hold a limit, so check:engine-double-contract (ledger rows recorded with --write), check:where-matcher and check:objectql-double-limit stay green.

Acceptance notes

  • The ui half (the Setup ban dialog showing the counts and offering the hand-over) is a separate domain:ui card; this PR does not touch ui/**.
  • A record already assigned to the banned user stays editable on purpose: refusing it would make every record they held uneditable until someone moved it.
  • The transfer does not run elevated. A row the caller could not change by hand is a reported failure, not a skipped one, so remaining tells the administrator what is left.

Verification

Head 6e44006f9, verified locally under the shared verify lock.

  • Real boot, before and after (pnpm dev -- --fresh, showcase, showcase_announcement.owner_id). "Before" is this head with the ban check and the route registration ablated in src and both packages rebuilt (ablation-dist-preflight proved the markers absent from dist/, then present again after restore and rebuild).

    step before after
    create a record owned by B, then ban B 201 201
    new record with owner_id: B 201 (assigned to a banned user) 400 VALIDATION_FAILED, fields[0].code: reference_user_banned
    edit the record that already holds B 200 200 (stored value not judged)
    GET …/users/B/assigned-work 404 200, showcase_announcement.owner_id count 2
    transfer to a banned user 404 400, toUserId / reference_user_banned, nothing written
    transfer to the admin 404 200: updated 1, failed 1 (RECORD_LOCKED, the edited record sits in an approval), remaining 1
  • Ablations (scripts/ablation-replace.mjs, wrap mode, each restored to the HEAD blob): deleting the ban push turns 4 of 9 engine-banned-user-reference tests red; counting with an empty filter turns the count test red; dropping the banned-target guard turns 2 refusal tests red; dropping the rest failure describer turns the hand-over row of the hook-refusal ledger red on every §6 shape.

  • Suites: rest 392 files / 7648 tests; core 141 / 3103 (security 63 / 1217 on the final head); objectql 552 / 10993; client 54 / 699. Typecheck: rest, core, objectql, client and spec clean.

  • Dogfood (narrowing arm): pnpm --filter @objectstack/dogfood test on 6e44006f9, lock VERDICT command-exit 0, Test Files 419 passed / 3 skipped (422), Tests 3496 passed / 36 skipped (3532). The first full run, on 13e254df, was red only in authz-conformance.test.ts (11 tests, the new family unclassified), which this head fixes.

  • Gates: all 128 families dispatch-gates.mjs --commands derives for this diff ran, and --ran reconciles 128 / 128 at exit 0. check:plugin-teardown-shape --self-test needed its pinned fixture commit fetched into this shallow clone first.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF

… a new assignment to a banned user (WIP)

Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF
Co-authored-by: Claude <noreply@anthropic.com>
…nd-over; add the SDK methods

Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF
Co-authored-by: Claude <noreply@anthropic.com>
…e, changeset and admin guide

Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF
Co-authored-by: Claude <noreply@anthropic.com>
…used row as the /data door does

The transfer report's per-row failure now carries the sentence the /data
write door answers for the same throw (a hook's refusal reads as the hook's
sentence, a crash is withheld and logged), through an optional describer
the core helper takes. The census pins gain the new route and registrar.

Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF
Co-authored-by: Claude <noreply@anthropic.com>
…hold the new doubles to the engine contracts

The user-work REST family arrives classified: a matrix row
(anonymous-deny-user-work) with its booted proof (anonymous 401 on both
routes, a member's count served and hand-over reaching its own target
check). The routes answer through the shared sendOk and are declared to
the route-envelope gate. The new test doubles route update through
assertEngineUpdateDispatch, refuse logical operators, and hold the limit.

Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF
Co-authored-by: Claude <noreply@anthropic.com>
…-dvfr9q

Resolves the administrator guide's offboarding row: main's wording (the ban
also blocks the user's API keys) with this branch's hand-over link.

Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:system tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/client, @objectstack/core, @objectstack/objectql, @objectstack/rest, @objectstack/spec, touching 82 documentable anchor(s).

65 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 17566664299edac816b6672a673ac82b192d24ea.

⛔ 16 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 cross-cutting symbol(s) contributed no route anchor: enforceAuth (29 routes), resolveExecCtx (34 routes), routeManager (23 routes), userId (5 routes), resolveRequestEnvironmentId (4 routes)
  • 3 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 80 pages), master_detail (literal, 40 pages), sys_user (literal, 49 pages)
  • 15 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 221 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 185 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 17566664299edac816b6672a673ac82b192d24ea → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 54c4508c65e4f01803245cc9c2da68b36bce5b53 — the merge of head a5de9d1e6e790b691b4a2f9fbb91f463fbbeddfe into base 17566664299edac816b6672a673ac82b192d24ea, 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 54c4508c65e4f01803245cc9c2da68b36bce5b53 && git checkout 54c4508c65e4f01803245cc9c2da68b36bce5b53
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 17566664299edac816b6672a673ac82b192d24ea a5de9d1e6e790b691b4a2f9fbb91f463fbbeddfe && git checkout -B drift-repro 17566664299edac816b6672a673ac82b192d24ea && git merge --no-ff a5de9d1e6e790b691b4a2f9fbb91f463fbbeddfe

node scripts/docs-audit/affected-docs.mjs --json 17566664299edac816b6672a673ac82b192d24ea

⚠️ 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 17566664299edac816b6672a673ac82b192d24ea → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…cond copy

#30287 landed isUserBanInForce and USER_BAN_FIELDS in security/user-ban.ts
while this branch carried its own; the hand-over and the reference check now
read that one, so a ban means one thing at every door.

Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF
Co-authored-by: Claude <noreply@anthropic.com>
…probe's own read

The ban check issued a find on sys_user of its own on every write naming a
user, which the plugin-audit read-cost pins (a create pays zero reads on
the reference targets) caught. The probe now selects the target's declared
ban columns beside the id (referenceRow, which referenceExists delegates
to), so the check costs no read and keeps the probe's elevation.

Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6d576db52ec3e72f7312a74ecb257683652a5fd8
Local-runs: probe — the narrowing face has no CI reading at this head that is both green and whole (two lanes red, the rest cancelled when the head moved), so the PR's own suites and the gates the dev re-ran after the main merge were run once in a throwaway worktree at 6d576db: pnpm --filter @objectstack/objectql exec vitest run src/engine-banned-user-reference.test.ts (9/9); pnpm --filter @objectstack/core exec vitest run src/security/user-assigned-work.test.ts src/security/second-object-read-exposure.pin.test.ts (15/15); pnpm --filter @objectstack/rest exec vitest run src/user-work-routes.test.ts src/rest-server-docblock-position.test.ts src/objectql-slot-consumer-census.test.ts src/rest-server-canonical-query-ast.test.ts src/write-route-hook-refusal-sentence.ledger.test.ts src/rest-write-response-internal-fields.tripwire.test.ts (193 passed / 21 skipped); pnpm --filter @objectstack/client exec vitest run src/admin-surfaces.test.ts (10/10); pnpm run check:route-envelope, check:adr-0087-registration, check:error-code-casing, check:engine-double-contract (all exit 0). Worktree removed afterwards.
Scope: packages/spec/src/api/errors.zod.ts, packages/spec/src/system/validation-message.ts (the reviewFace hits) + the narrowing-face floor, every non-prose path of the diff: packages/objectql/src/engine-referential-integrity.ts, packages/objectql/src/engine.ts, packages/objectql/src/engine-banned-user-reference.test.ts, packages/core/src/security/user-assigned-work.ts, packages/core/src/security/user-assigned-work.test.ts, packages/core/src/security/index.ts, packages/core/src/security/second-object-read-exposure.pin.test.ts, packages/rest/src/user-work-routes.ts, packages/rest/src/user-work-routes.test.ts, packages/rest/src/rest-server.ts, packages/rest/src/rest-route-ledger.ts, packages/rest/src/rest-server-registrar-files.ts, packages/rest/src/rest-server-docblock-position.test.ts, packages/rest/src/rest-server-canonical-query-ast.test.ts, packages/rest/src/objectql-slot-consumer-census.test.ts, packages/rest/src/write-route-hook-refusal-sentence.ledger.test.ts, packages/rest/src/write-response-internal-fields.tripwire.test.ts, packages/client/src/index.ts, packages/client/src/admin-surfaces.test.ts, packages/qa/dogfood/test/authz-conformance.matrix.ts, packages/qa/dogfood/test/authz-conformance.test.ts, packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts, scripts/check-route-envelope.mjs, scripts/engine-double-contract.pinned.json + cited code read: packages/core/src/security/user-ban.ts (origin/main, #30287), packages/objectql/src/engine.ts insert/update/count/getConfigs, packages/core/src/utils/import-runner.ts, packages/rest/src/batch-routes.ts, packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts, packages/types/src/response-envelope.ts, packages/plugins/plugin-audit/src/audit-milestone-summary.test.ts, docs/adr/0112, AGENTS.md step 3, .claude/skills/pm-dispatch/references/lanes/spec.md. Prose faces (.changeset/30205-banned-user-work-handover.md, content/docs/**) are the lane seat's ACCEPT, read here only for the declaration lines.

① Derived judgments

  1. The narrowing, as the tree implements it. assertReferencesResolve (engine-referential-integrity.ts) collects every caller-supplied, non-empty, non-object value of a non-readonly field whose referenceTargetOf is sys_user (a lookup to sys_user, a user field, a multi-valued member each on its own, a master_detail to sys_user too) that the existence probe resolved, then runs ONE find on sys_user ($in) under { ...context, isSystem: true } and pushes a reference_user_banned entry for each id isUserBanInForce holds. Correct: the refusal is per field, per banned member, 400 VALIDATION_FAILED with fields[].code = reference_user_banned (pinned by the objectql suite, 9/9 here). Objects: every object the engine writes, sys_* included — the write check has no platform-table exclusion, so a sys_user_permission_set.user_id grant to a banned user is refused too. The changeset's sentence ("a user field: the owner field, a user field, or a lookup to sys_user") covers that literally; the count/transfer half excludes sys_* on purpose, so the two halves are deliberately asymmetric. Judged correct, worth one clause in the changeset but not a defect.
  2. Write paths. The check lives in ObjectQL.insert (single and array form, engine.ts call site 6824) and ObjectQL.update (by-id 8657 and predicate/multi 9093). That carries: POST/PATCH /api/v1/data/OBJECT; the batch door (batch-routes.ts calls ql.insert/ql.update per op); the import door (import-runner.ts writes through the engine's array-form insert()); a hook's ctx.api under a non-system context; a flow's runAs: 'system' write (keeps the check via keepsFlowWriteReferenceCheck). Exempt: any isSystem write without the flow mark (seed, install, restore, cascades), exactly as reference_not_found. The changeset's "every data write door" line is accurate.
  3. NEW assignment only. The data: a multi-user field keeps a deleted user's id, so a later update carrying the field unchanged is refused reference_not_found #29062 stored-row filter runs AFTER both pushes (refused.filter(... stored.every(row => storedReferenceIds(row[r.name]).has(value)))), so a value every targeted row already stores is forgiven for the banned code as well. Verified in code and by the suite ("a row that already holds the banned user stays editable") and by the dev's real-boot row (200). Reads are untouched (the check is write-path only). Correct.
  4. Fail-open on the ban read, warn once, is the probe's own posture (an integrity check that cannot run must not invent a rejection). Correct and consistent.
  5. Cost of the narrowing: one extra sys_user read per non-system write that names a user. This is real and it is what reds CI: three plugin-audit read-count pins (audit-milestone-summary.test.ts:635 "a create pays ZERO reads" now sys_user: 1; audit-option-label-summary.test.ts:563 1 vs 0; implicit-reference-target-summary.test.ts:316/354 2 vs 1). The milestone test inserts biz_deal with owner: 'usr_1' under no isSystem, which is exactly the shape the new find fires on, and none of the 42 main commits merged into this head touches packages/plugins/plugin-audit, so this is the PR's red, not the base's. Must-fix (either fold the ban columns into the existing existence probe so the write pays no second read, or re-pin the three suites with the ruling recorded in them).
  6. FieldErrorCode gains reference_user_banned (errors.zod.ts) with four locale templates (validation-message.ts). It is a published closed response vocabulary (FieldErrorSchema.code, ui/action-params.zod.ts code: FieldErrorCode), so the accept-set of the response schema WIDENS by one member: a consumer that switches exhaustively gains a case; nothing authorable moves (Metadata-protocol: none is right). The branch-information test of lanes/spec.md is met: a banned user versus a missing one is a branch a consumer acts on differently (offer the hand-over / pick another user). The regenerated errors.mdx matches (+22 more; the two lists).
  7. REST user-work family. Both handlers: resolveExecCtx → enforceAuth (401 before any read; the dogfood matrix row anonymous-deny-user-work and its booted proof read, member controls 200 / 404 included) → refuseUnknownQueryParams (closed, empty set) → closed body (toUserId, objects?) → engine via wiredEngineOrLoud (501 when none). Count: one engine.count(object, { where, context }) per (object, user-field) pair under the CALLER's context, sys_*/managedBy/disabled-package/readonly/system/phantom columns excluded, a thrown count names the object in unreadable, cap 200 pairs (truncated). plugin-security's authz middleware lists count beside find/findOne/insert (security-plugin-authz-middleware.ts:448), so the count is RBAC/RLS-gated. Transfer: target read ONCE elevated inside the tenant ({ ...context, isSystem: true }, USER_BAN_FIELDS), refused 404 RESOURCE_NOT_FOUND/reference_not_found, 400 VALIDATION_ERROR/reference_user_banned, or 400 same-user — all before any write (core and rest suites pin update not called); then per single-valued field find(ids, limit=budget) and one engine.update(object, { [field]: toUserId }, { where: { id }, context }) per row under the caller's context — the ordinary path, so sharing, validation rules, hooks, approval locks and the reference check itself (the target is active) apply; failures are described through handleRouteError (the /data door's sentence, hook-refusal ledger row read), capped at 10 per field; ≤500 rows per call; remaining recounted per field. Envelope: sendOk/sendError only, check:route-envelope at zero (gate green here); every code used (VALIDATION_ERROR, RESOURCE_NOT_FOUND, NOT_IMPLEMENTED, INTERNAL_ERROR) is in ErrorCode; the refusal carries error.details.fields[0].{field,code}. Judged correct.
  8. Cost and caps on the count. Up to 200 count queries per call, for any authenticated caller, on any user id, with no memo and no per-route limit beyond the field cap. Bounded, and each query is one the caller could issue by hand through /data; acceptable for an administrator surface, recorded as a reading for the ui half (do not poll it).
  9. Observability of a ban. Any authenticated caller who can write a user field, or call the transfer route, can now learn that a given user id exists and is banned (the reference probe already disclosed existence). This is the card's third bullet working as asked, not a leak of anything the ban dialog does not itself show; recorded so the ui half does not treat the code as secret.
  10. One ban predicate (fix(core)!: a banned user's credentials are refused while the ban holds, at every door #30287). At this head isUserBanInForce/USER_BAN_FIELDS have one owner, packages/core/src/security/user-ban.ts on main; objectql imports them through @objectstack/core (exported at security/index.ts:182 on main), user-assigned-work.ts through ./user-ban.js. No second banned/ban_expires predicate survives outside user-ban.ts in core, objectql or rest (grep at head). The dev's own copy was removed in 3092798. No drift.
  11. Census rows. REST_SERVER_REGISTRAR_FILES, the route ledger (sdk rows with client methods present in client/src/index.ts), the docblock floor (6, sum 101), canonical-query floor 0, slot-consumer census 5→6, the second-object read-exposure classification, the internal-fields tripwire and hook-refusal ledger rows, the engine-double-contract pins: all read and all pass here.
  12. A misplaced docblock in rest-server.ts. registerUserWorkEndpoints was inserted BETWEEN registerSecurityExplainEndpoints' docblock and its body: the explain docblock ("…/security/explain… D12 manage_users…") now heads the user-work registrar, and the new [#30205] Register a user's assigned-work routes docblock heads registerSecurityExplainEndpoints. The position pin only counts blocks, so it stays green. Must-fix, mechanical.
  13. Doc/skill authoring guard red at this head (PR-caused): authz-conformance.matrix.ts:322 carries #30205 inside the runtime note: string of the new matrix row; the guard refuses a tracker id in a runtime string. Must-fix, mechanical (move the id to the comment above the row).

② Semver level

  • Kind: Metadata-protocol: none — correct, nothing an author writes moves (FieldErrorCode is a response vocabulary; the ADR-0087 marker's own sentence says the same and check:adr-0087-registration accepts not-required (no-migration-prescription) at this head). Level follows the kind alone: patch on spec, core, objectql, rest, client; all five are private: false and all five carry the changeset. The BREAKING banner, the !, the single ADR-0087 marker and the Skills-docs: line are present. Level and marker: consistent with AGENTS.md step 3.
  • Declaration: Clause-②: no (narrowing) is NOT whole. The diff widens the public face (a member added to a published closed enum, packages/spec/src/api/errors.zod.ts), and lanes/spec.md and landing-operations.md make widening the public face clause ② (放宽接受集或扩大公开面…即条款②; 错误的 no 是可审计的假申报), while a narrowing alone would not be. The closed pair allows one arm, and the arm that is BREAKING must stay, so the whole declaration is Clause-②: yes (narrowing) in the PR body and the changeset body. The tier this review serves is unchanged either way (the path arm packages/spec/src/** hits), so this is a declaration fix, not a re-review. Must-fix.
  • Lane: packages/spec 恒归 domain:spec, but the spec lane pre-approves a gate-enforced, append-only vocabulary member when (a) the code's existence was ruled on the owning card (Offboarding: banning a user says nothing about their open work, and there is no way to hand it over #30205, maintainer's word: "refused at the server"), (b) the fallback is unchanged and the clause-② path arm still goes through contract review (this record), (c) it is a pure append with no shape change. All three hold for reference_user_banned and its four templates; the pre-approval text names error-code-ledger.zod.ts, and FieldErrorCode is the field-level sibling of that ledger (ADR-0114 D2), so I judge the edit acceptable in this lane under that class, with no spec-lane signature owed. The dispatch seat names this in ACCEPT.

③ Boundary flags

  • open_questions: [], security_findings: none in the dev report. No flag raised by the dev stands unanswered.
  • Author: objectstack-fleet[bot] (relay for os-bill, session_01Utp6CZavYYLh5k5r6jhgCF); no external login to name.
  • Gates at this head. The dev's 128/128 dispatch-gates reconciliation and the full dogfood battery (os-verify-lock exit 0, 419 files / 3496 tests) are pinned to 6e44006f, BEFORE the merge of main (42 first-parent commits, among them fix(core)!: a banned user's credentials are refused while the ban holds, at every door #30287 which rewrote core/security/index.ts, api-key.ts, resolve-authz-context.ts, added user-ban.ts and three dogfood tests; fix(plugin-security): a by-id write's row re-read propagates a store fault, and judges the platform's own predicates without the caller-predicate guard #30307 in plugin-security; fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270 and fix(lint): field-path rules judge an object as served, so a field an objectExtensions entry adds is not refused (#30383) #30432 in lint), the refactor onto main's predicate (3092798) and the D6 marker (6d576db). After the merge only seven gates and the targeted suites ran; the authz pair re-ran 139/139. Unmeasured at this head: the other 121 gate families, the plugin-audit suite (which CI shows red), and the full dogfood battery. review-checklist: for a (narrowing) PR the dogfood_battery line must be pinned to the head, full, red fixed. It is not. The battery must be re-run at the head that lands (the head has already moved to ed851815; the record there needs its own line).
  • Check-runs read on this head (REST API, 2026-11-05 ~16:00Z): Lint & Repo Gates failure (required); Lint · repo & tooling gates failure (59/60, red: Doc/skill authoring guard, item ①13); Test Core (5/8) failure (plugin-audit 3 files, item ①5) and Test Core failure; Dogfood Regression Gate success is the attestation-verify step only — the three shards were skipped, so it measures nothing on a PR; the type-check, ESLint, dispatch-gates, Build Core, remaining Test Core/Test UI shards and Type Check UI were cancelled when the head moved, so they have no reading at this head. Green: Governed Surface Queue Guard, Check Changeset, PR-body guards, Spec property liveness, Check Documentation Links, Flag docs, filter. The merge base c045c341 is not the source of either red (the failing files are untouched by the merged commits and the assertion deltas are the PR's read).
  • Fixes #30205 closes a card whose first two Expected bullets (the ban dialog's counts and the hand-over offer) are the domain:ui half. The PR body says that half is "a separate domain:ui card", but the card's timeline shows no such card. review-checklist: landing only the implementable half ⇒ Part of #30205, unless the ui card is filed first and the maintainer wants Offboarding: banning a user says nothing about their open work, and there is no way to hand it over #30205 closed by the server half. For the lane seat to settle before ready (not card-worthy on its own).
  • The PR body's "Two-minute verification" line and the objectql sentence already name core's security/user-ban.ts (#30287); the body edit the dev report asks the seat to make is already in place at this head.

④ Design judgment

  • The narrowing (declared face, no hits of its own beyond the spec pair): holds against the card's third bullet and the measured defect (POST /data/asset_repair_ticket with assignee = banned user answered 201). The placement — inside the write-time reference check, on the same terms as reference_not_found (caller-supplied, ADDED values only, system writes exempt, flow writes kept, elevated sudo()-shaped read inside the tenant, fail-open with a warning) — is the right seam: there is one owner for "a reference the caller named", and the ban predicate has one owner in core. Keeping a stored banned value editable is the correct trade (the alternative freezes every record the person held). Multi-valued fields refused per member is right. The one design cost is the second sys_user round-trip per write that names a user; the plugin-audit pins exist precisely to make such a cost visible, and they did. Either folding the ban columns into the existence probe's read (one round-trip, as the card's "one extra find" sentence could be made true) or re-pinning with the ruling in the test is acceptable; shipping with the pins red is not.
  • errors.zod.ts / validation-message.ts against ADR-0114 D2 and their first consumer: a closed catalog gains a member whose meaning is distinct from reference_not_found and whose template names the field, the id and the remedy ("pick an active user") in four locales; buildFieldError is the first consumer and already takes the code through r.code ?? 'reference_not_found'; the adr0112-ok: D6 marker is on the literal. Holds.
  • The user-work family against the card's first two bullets: counting under the caller's context and transferring through ordinary updates is the right posture (the card asks for "across objects the administrator can read"; a transfer that elevated would move rows the administrator could not touch by hand). Excluding sys_*, managedBy, readonly/system fields is right (a grant is not work; provenance is not reassigned). Refusing a banned target before any write closes the loop with the narrowing. The caps and remaining make it repeatable. The rules living in @objectstack/core because rest cannot import objectql at runtime is a sound dependency decision and the only engine.ts touch is the four-line host literal. Holds, subject to ①12 (docblock) and ①13 (guard).
  • Nothing in docs/adr/**, skills/** or .claude/** moves; no governed path in the file list (Governed Surface Queue Guard green).

Implemented-by: claude/project-thread-dvfr9q
Reviewed-by: session_01Utp6CbPxYq1wQmUNWvMZkC

VERDICT: FAIL

Must-fix, each on its own line for the card:

  1. Test Core red is the PR's: three plugin-audit read-count pins (audit-milestone-summary.test.ts:635, audit-option-label-summary.test.ts:563, implicit-reference-target-summary.test.ts:316,354) count the new ban-state find on sys_user; fold the read into the existence probe or re-pin with the ruling, and make CI green.
  2. Doc/skill authoring guard red is the PR's: #30205 inside the runtime note: string at packages/qa/dogfood/test/authz-conformance.matrix.ts:322.
  3. dogfood_battery is pinned to 6e44006f, before the main merge; a (narrowing) PR needs the full battery pinned to the head that lands.
  4. Clause-②: no (narrowing) → Clause-②: yes (narrowing) in the PR body and the changeset body (the spec enum widens the public face).
  5. rest-server.ts: the registerSecurityExplainEndpoints docblock now heads registerUserWorkEndpoints and vice versa; swap them back.

…are Clause-② yes (narrowing)

The user-work registrar had been inserted between the security-explain
registrar's docblock and its body, so each method carried the other's
docblock. It now sits after registerSecurityExplainEndpoints, and the diff
against main is a pure addition.

The changeset declares Clause-②: yes (narrowing): the published
FieldErrorCode enum gains a member, which widens the public face.

Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF
Co-authored-by: Claude <noreply@anthropic.com>
Resolves the engine.ts conflict with the #29986 codemod's fourth pass
(#30463), which moved referenceExists' body into
engine-references-and-master-detail.ts: referenceRow moves there with it,
referenceExists delegates to it in the facet, and engine.ts keeps a
one-line stub and the host slot.

Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF
Co-authored-by: Claude <noreply@anthropic.com>
…here the read-seam baseline licenses it

The main merge had moved the catch into referenceRow, so the read-seam
rule flagged a new silent catch and reported the referenceExists baseline
entry stale. referenceRow now lets a read error through; referenceExists
answers it null as before; the ban path catches it itself, logs a warning
and fails open like the existence probe. A test pins the fail-open path.

Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

Contract review (delta)

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a5de9d1e6e790b691b4a2f9fbb91f463fbbeddfe
Local-runs: probe — the delta re-measures the five round-0 must-fixes and the two new seams the main merge produced (the referenceRow facet split and the fail-open catch), so the gates and suites they turn on were run once in a throwaway worktree at a5de9d1 after pnpm install --frozen-lockfile and OS_SKIP_DTS=1 turbo run build --filter=@objectstack/objectql... --filter=@objectstack/plugin-audit... (16/16 tasks, 0 cached): pnpm --filter @objectstack/objectql exec vitest run src/engine-banned-user-reference.test.ts (10/10); pnpm --filter @objectstack/plugin-audit exec vitest run src/audit-milestone-summary.test.ts src/audit-option-label-summary.test.ts src/implicit-reference-target-summary.test.ts (3 files, 51/51 — the three round-0 reds, their assertions untouched by the PR); node scripts/check-facade-delegation-only.mjs (self-test and gate, exit 0: 5 facade classes in 3 files hold delegations only); node scripts/check-engine-double-contract.mjs (self-test and gate, exit 0, 1135 pinned rows); node scripts/check-route-envelope.mjs (self-test and gate, exit 0, user-work-routes.ts at 0/0/0); node scripts/check-doc-authoring.mjs (self-test and gate, exit 0); node scripts/check-durability-degradation-log-level.mjs (exit 0: 52 durability seams loud or propagating; read-seam rule 102 seams, none inventing, 1 baselined = referenceExists, baseline untouched). Worktree removed afterwards.
Scope: packages/spec/src/api/errors.zod.ts, packages/spec/src/system/validation-message.ts (the reviewFace hits, unchanged since round 0) + the narrowing-face floor, every non-prose path of the diff against merge base 2ffdb64: packages/objectql/src/engine-references-and-master-detail.ts (NEW in the delta, from the main merge), packages/objectql/src/engine-referential-integrity.ts, packages/objectql/src/engine.ts, packages/objectql/src/engine-banned-user-reference.test.ts, packages/core/src/security/{index.ts,user-assigned-work.ts,user-assigned-work.test.ts,second-object-read-exposure.pin.test.ts}, packages/rest/src/{user-work-routes.ts,user-work-routes.test.ts,rest-server.ts,rest-route-ledger.ts,rest-server-registrar-files.ts,rest-server-docblock-position.test.ts,rest-server-canonical-query-ast.test.ts,objectql-slot-consumer-census.test.ts,rest-write-route-hook-refusal-sentence.ledger.test.ts,rest-write-response-internal-fields.tripwire.test.ts}, packages/client/src/{index.ts,admin-surfaces.test.ts}, packages/qa/dogfood/test/{authz-conformance.matrix.ts,authz-conformance.test.ts,showcase-anonymous-deny-surfaces.dogfood.test.ts}, scripts/check-route-envelope.mjs, scripts/engine-double-contract.pinned.json + cited code read: packages/core/src/security/user-ban.ts (USER_BAN_FIELDS, isUserBanInForce), packages/platform-objects/src/identity/sys-user.object.ts (banned, ban_expires declared), scripts/durability-read-invention.baseline.json (the referenceExists entry), scripts/check-facade-delegation-only.mjs header, scripts/facade-delegation-registry.json, the merge commit ab3fc8d against both parents, docs/adr/0110 D3, AGENTS.md "Absence must be loud" and "Degradation log levels". Prose faces (.changeset/30205-banned-user-work-handover.md, content/docs/**) are the lane seat's ACCEPT, read here only for the declaration lines. The round-0 record (comment 6662784185) is the baseline; items it judged and the delta does not touch are not re-stated.

① Derived judgments

Per round-0 must-fix, verified from the tree at a5de9d1:

  1. Must-fix 1 (the extra sys_user read) — FIXED, by folding. The separate find on sys_user is gone from assertReferencesResolve. For a non-master_detail field whose referenceTargetOf is sys_user the check now calls this.referenceRow(target, v, context, USER_BAN_FIELDS) once per value — the SAME findOne the existence probe issues (engine-references-and-master-detail.ts referenceRow: where: { id }, fields: ['id', ...extraFields] filtered to the names the target declares and not id, context: ObjectQL.referenceCheckContext(context)), and referenceExists itself now delegates to referenceRow(target, id, context, []), so every other target pays exactly what it paid before. One read answers both existence (false → reference_not_found) and ban (isUserBanInForce(row) → reference_user_banned). sys_user declares both ban columns (sys-user.object.ts lines 979 and 995), so the f in declared filter keeps them; a user object without them answers "not banned", which is the right reading of "no ban concept". Measured: the three plugin-audit read-count pins pass 51/51 with their assertions byte-untouched (git diff 2ffdb649..a5de9d1e -- packages/plugins/plugin-audit is empty), and Test Core and all eight shards are success at this head. No re-pin, no ruling owed.
  2. Must-fix 2 (#30205 in a runtime note: string) — FIXED. authz-conformance.matrix.ts row anonymous-deny-user-work: the tracker id lives only in the two comments above and inside the row (// #30205 — a user's assigned work…, // Added by #30205.); the note: string carries none. check:doc-authoring exit 0 locally; Lint · docs & code guards and Lint & Repo Gates success at this head.
  3. Must-fix 3 (dogfood battery pinned to the landing head) — OWED, not blocking this record. The PR body's Verification still pins the battery to 6e44006f9. The dev's reading at a5de9d1 is still being produced; recorded here as owed by the dev's report, at a5de9d1 (full battery, exit 0, red fixed or filed). The seat checks it before ready; it is a landing precondition, not a code defect in this tree. The Dogfood Regression Gate success at this head is the attestation step only — its three shards are skipped on a PR, as in round 0, so CI measures nothing here.
  4. Must-fix 4 (Clause-②: yes (narrowing)) — FIXED in the changeset, NOT in the live PR body. .changeset/30205-banned-user-work-handover.md now reads Clause-②: yes (narrowing) — the published FieldErrorCode enum gains the member reference_user_banned. …, Metadata-protocol: none, Skills-docs: present, one adr-0087: not-required (no-migration-prescription) marker, ! and BREAKING banner kept. The live PR body (read via gh api pulls/30546 --jq .body at review time) still carries Clause-②: no (narrowing) and Fixes #30205. The dev's corrected body draft was NOT found: /mnt/project-files/server-seat3/30205/pr-body-r1.md does not exist and server-seat3/ has no 30205 entry, so the live body is what this record judged. The body is the seat's write at hand-over; its stale sentences are listed under landing preconditions below. The tier is unchanged either way (the packages/spec/src/** path arm hits).
  5. Must-fix 5 (the swapped docblocks in rest-server.ts) — FIXED. At head the registerSecurityExplainEndpoints docblock (…/security/explain… D12 manage_users…) ends at the line directly above private registerSecurityExplainEndpoints(basePath), and the [#30205] Register a user's assigned-work routes docblock ends directly above private registerUserWorkEndpoints(basePath) (lines 6184 and 6214). The position pin still counts 6 blocks; the content pairing is now right.
  6. Fixes #30205 → Part of #30205 — NOT done in the live body (same reading as item 4). The ui half (ban-dialog counts and hand-over offer) remains open on the card; review-checklist: only the implementable half lands ⇒ Part of.

The new code from the main merge (b) and the posture fix (c):

  1. Facet split is facade-legal. main's Codemod split: generate the objectql engine.ts split with a deterministic script, verified byte-identical #29986 codemod (refactor(objectql): type the six #4918 erasure-pinned ObjectQL methods and move their bodies out of engine.ts with the codemod's fourth pass #30463) moved referenceExists' body to engine-references-and-master-detail.ts; the merge commit ab3fc8d resolves engine.ts by keeping only a one-line stub for referenceRow (return ReferencesAndMasterDetailFacet.referenceRow.call(referencesAndMasterDetailHost.of(this), …)), the referenceRow forwarder in the referencesAndMasterDetailHost factory, and in the assertReferencesResolve host literal a const readLogger arrow returning this.logger, a get logger() accessor returning readLogger(), and a referenceRow arrow forwarding its arguments to this.referenceRow — the exact .call(host, …) grammar the gate's header names (a const forwarding arrow, a host literal of forwarders, one forwarding call). check:facade-delegation-only exit 0 locally and Lint & Repo Gates success at head. Every other engine.ts hunk in the merge diff against parent 1 is main's own codemod (import pruning, engine-top-level.ts re-exports); the diff against the merge base 2ffdb64 is the PR's 10 lines and nothing else. ReferencesAndMasterDetailHost gains referenceRow: ObjectQLClass['referenceRow'], ReferentialIntegrityHost gains readonly logger and referenceRow, each typed as the class member, so the host-Pick invariant that module header states holds; TypeScript Type Check success at head. check:engine-double-contract exit 0 with the two new pinned rows (user-assigned-work.test.ts and user-work-routes.test.ts, verb update).
  2. The catch moved to the right place. referenceRow does not catch: a thrown read propagates. referenceExists keeps its try/catch → null, which is the seam the read-invention baseline licenses (durability-read-invention.baseline.json entry for referenceExists, verdict reviewed-legitimate, byte-untouched by the PR). The new catch sits in assertReferencesResolve's sys_user branch, around this.referenceRow(...): it logs warn with { object, field, error } and sets row = null. referenceRow is not a vocabulary name of the read-seam rule, and the catch logs, so the rule has nothing to flag; locally the gate reports 102 read seams, none inventing, exactly 1 baselined. No new baseline entry, no DURABILITY_CRITICAL_CALLEES change. Correct.
  3. FAIL OPEN on a failed ban-column read — ACCEPTABLE, and louder than the sibling. Compared with the existence probe today: referenceExists catches, returns null, logs nothing, and assertReferencesResolve treats null as "not refused" — the documented fail-open on its docblock ("an integrity check that cannot run must not invent a rejection… converts a connectivity problem into data loss"), reviewed and recorded in the read-invention baseline. The ban path at head does the same thing with a warn naming the object, the field and the error, so for a sys_user target the posture is strictly louder than it was before the PR, and the one case it leaves unrefused during an outage (a write that assigns a banned or missing user) is the same case the sibling already left unrefused, now visible in the log. Against the two authorities the review was asked to weigh: AGENTS.md "Absence must be loud" is met (the degradation is logged; it is a functional degradation of a validation, not a durability loss, so warn is the right level under "Degradation log levels", and no caller is misled about what was persisted). ADR-0110 D3 ("an availability failure is not an authorization decision; the gate an author declared must not evaporate during an outage", 503) governs an AUTHORIZATION gate on action dispatch; reference_user_banned is a referential-integrity validation on the referent's state, answered 400 VALIDATION_FAILED beside reference_not_found, not a decision about what the caller may do — the caller is an authorized writer either way, and the banned user acts in nothing. D3 therefore does not reach it, and the sibling check's documented posture is the governing precedent. Explicitly: the fail-open is accepted. Two readings for the card, not defects: (a) the warn fires once per value whose read threw (inside the per-value loop), so a multi-valued field during an outage warns once per member — the field name in each line makes that diagnosable, and the sibling logs nothing; (b) a master_detail to sys_user is excluded from the ban branch (def?.type !== 'master_detail') and keeps the header path — round 0 read the earlier head as ban-checking it; the changeset sentence ("the owner field, a user field, or a lookup to sys_user") never named master_detail, so the tree now matches the published sentence literally.
  4. The fail-open pin. Test 10 of the objectql suite ("a user read that throws refuses nothing and logs a warning, as the existence probe fails open") registers a middleware that throws on findOne of sys_user, asserts the insert lands with assignee intact and that a warn containing "could not read the referenced user" was emitted. It pins the posture, the level and the sentence (which carries no tracker id). The other nine are unchanged from round 0. 10/10 locally.
  5. Nothing else moved. The file list against the merge base is the round-0 list plus engine-references-and-master-detail.ts; user-assigned-work.ts, the REST family, the client surface, the census rows and the spec pair are byte-identical to the round-0 reading except as named above. Round-0 items ①1–①4 and ①6–①11 stand.

② Semver level

  • Kind Metadata-protocol: none, level patch on spec, core, objectql, rest, client (all private: false, all in the changeset): unchanged and correct (round 0 ②). The changeset's declaration is now Clause-②: yes (narrowing), which is the whole declaration for a diff that widens the public face (a published closed enum gains a member) and narrows an accept-set at the write doors. Check Changeset and check:adr-0087-registration lanes success at head.
  • The PR body's declaration line disagrees with the changeset (no (narrowing)), and the body still reads Fixes #30205; see landing preconditions. Not a code must-fix: it is the seat's body write, judged on the live body because the draft was absent.
  • Spec-lane pre-approval reading from round 0 (gate-enforced, append-only vocabulary member ruled on the owning card, no shape change) stands; the dispatch seat names it in ACCEPT.

③ Boundary flags

  • Author objectstack-fleet[bot] (relay for os-bill, session_01Utp6CZavYYLh5k5r6jhgCF); no external login.
  • Check-runs at a5de9d1 (REST, 65 runs, read 2026-11-05 ~17:45Z): every run is success or a paths skipped; no failure, no cancelled. Required set: Lint & Repo Gates success, TypeScript Type Check success, Test Core success (and shards 1–8/8 success), Build Core success, Governed Surface Queue Guard success, Lint UI skipped (no ui/** path), Type Check UI success, Test UI success (16/16 shards), Build & E2E UI success, Live E2E UI success. Advisory: all Lint · and Type Check · lanes success (including Lint · docs & code guards and Lint · PM dispatch-gates), Check Changeset, PR-body guards, Spec property liveness, Check Documentation Links, Flag docs, Check PR Size, Auto Label success; Dogfood Regression Gate success is the attestation step with its three shards skipped, so the battery is unmeasured by CI (item ①3). Both round-0 reds (Lint · repo & tooling gates, Test Core (5/8)) are green at this head.
  • Governed surfaces: none in the file list (Governed Surface Queue Guard success). Tier S/H not engaged; the PR is above neither the 3,000-line nor any other special-landing threshold (1499/22).
  • open_questions: [], security_findings: none from the dev; nothing security-shaped beyond the PR's own stated rule was seen in the delta.
  • Landing preconditions, body-side (the seat's write, before ready): (a) Clause-②: yes (narrowing) on the body line; (b) Fixes #30205 → Part of #30205 with zero verbs beside the number; (c) the sentence "It is one extra find on sys_user per write ($in for several ids)" is false at this head — the ban columns ride the existence probe's own read, no extra read; (d) "it now hands find and logger to assertReferencesResolve" → it hands logger and referenceRow; (e) Verification/battery pinned to 6e44006f9 → the a5de9d1 reading the dev owes; (f) "9 tests" in the two-minute verification → 10.

④ Design judgment

  • The narrowing at this head is the round-0 design with its one cost removed: the ban columns are selected on the probe's existing findOne, so the write pays exactly what reference_not_found paid, and the plugin-audit read pins — which exist to make such costs visible — confirm it at zero delta. One seam (referenceRow) owns "the row the caller named", one predicate (user-ban.ts) owns "is the ban in force", and the facade keeps only forwarders. The catch placement (facet method throws; the integrity check decides, logs and fails open; referenceExists keeps its baselined null) is the shape the read-invention rule asks for: distinguishability preserved, the failure reported once where the decision is made. Holds.
  • errors.zod.ts / validation-message.ts against ADR-0114 D2 and buildFieldError as first consumer: unchanged from round 0, holds.
  • The user-work family and the census rows: unchanged from round 0, hold; the docblock pairing in rest-server.ts is now correct.

Implemented-by: claude/project-thread-dvfr9q
Reviewed-by: session_01Utp6CbPxYq1wQmUNWvMZkC

VERDICT: PASS

Not a must-fix on this tree, but owed before ready and read by the seat at hand-over: the live PR body (declaration line Clause-②: yes (narrowing), Part of #30205, the four stale sentences in ③), and the dev's dogfood battery reading pinned to a5de9d1 (full, exit 0, red fixed or filed).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Offboarding: banning a user says nothing about their open work, and there is no way to hand it over

1 participant