Repository navigation
feat(objectql,rest)!: refuse a new assignment to a banned user; count and hand over their work - #30546
Conversation
… a new assignment to a banned user (WIP) Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <[email protected]>
…nd-over; add the SDK methods Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <[email protected]>
…e, changeset and admin guide Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <[email protected]>
…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 <[email protected]>
…rescription Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <[email protected]>
…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 <[email protected]>
…-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 <[email protected]>
📓 Docs Drift CheckThis PR changes 5 package(s): 65 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 16 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 185 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
…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 <[email protected]>
…ADR-0112 D6) Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <[email protected]>
…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 <[email protected]>
|
domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Implemented-by: VERDICT: FAIL Must-fix, each on its own line for the card:
|
…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 <[email protected]>
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 <[email protected]>
…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 <[email protected]>
|
domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC Contract review (delta)Served-tier: ① Derived judgmentsPer round-0 must-fix, verified from the tree at a5de9d1:
The new code from the
② Semver level
③ Boundary flags
④ Design judgment
Implemented-by: 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 |
|
domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC 维护者速读(席位终稿)
席位意见:同意落地。拒绝只针对新指派,已有记录不受影响;五处必改都已在代码里核实。 席位核对ACCEPT · head
|
domain:server#3 · os-bill · session_01Utp6CZavYYLh5k5r6jhgCF
Requested by Bill · project thread
Part of #30205
Clause-②: yes (narrowing) — the published
FieldErrorCodeenum gains the memberreference_user_banned, and a non-system create or update that sets a user field to a user whose ban is in force is refused400 VALIDATION_FAILED/reference_user_banned, a write that answered201/200before. A value the row already stores is not judged.Metadata-protocol: none
维护者速读
reference_user_banned);新增两条接口,按对象统计某用户名下的记录,并可一次性把它们转给一位在用的用户。packages/objectql/src/engine-referential-integrity.ts(拒绝逻辑)、packages/objectql/src/engine-references-and-master-detail.ts的referenceRow(探测读取顺带取封禁列)、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,应看到 10 个测试全部通过。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 whose target issys_user, whether that user's ban is in force (bannedis true andban_expiresis empty or still ahead), and refuses it with the field codereference_user_bannedbesidereference_not_found. The ban columns come back in the existence probe's own read (referenceRow, which selectsidplus theUSER_BAN_FIELDSthe target declares), so a write that names a user pays no read beyond the one it already paid, and the read keeps the probe's elevated, tenant-scoped context: 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'srunAs: 'system'keeps the check, as it does forreference_not_found. If the probe's read itself fails, the write is refused for neither reason, as before, and a warning names the field. The check covers every object the engine writes,sys_*tables included (a permission-set grant to a banned user is refused too), while the count and hand-over below skipsys_*on purpose.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,userfields and lookups tosys_usercount; platform tables (sys_*),managedByobjects, disabled packages andreadonly/systemfields such ascreated_bydo not. Objects the caller cannot read are named inunreadable; at most 200 fields are counted (truncated). Each count is one query the caller could issue through/data; it is an administrator surface, not one to poll.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 ordinaryupdateunder 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/datadoor answers for the same throw, at most 10 per field) and how many remain. A banned, unknown or identicaltoUserIdis refused before any write. Multi-valued user fields are counted but not transferred. Neither route touches the ban: the ban staysPOST /api/v1/auth/admin/ban-user, urgent and alone.The new
FieldErrorCodemember@objectstack/spec'sFieldErrorCodeis a closed response vocabulary, and this PR adds one member,reference_user_banned, with its message template inen,zh-CN,ja-JPandes-ES(validation-message.ts). That widens the published response face by one member, which is why the declaration isClause-②: yes. It is not an authorable key: no schema an author writes gains, loses or reshapes anything, which is why the changeset carries the ADR-0087not-required (no-migration-prescription)marker andMetadata-protocol: none. A client that switches on the closed set gains one case; the regeneratedcontent/docs/references/api/errors.mdxlists it.Where the code lives, and the
engine.tstouch@objectstack/core(security/user-assigned-work.ts):@objectstack/resthas objectql only as a dev dependency, so a module both can import at runtime has to sit in core. The ban predicate has one owner, core'ssecurity/user-ban.ts(fix(core)!: a banned user's credentials are refused while the ban holds, at every door #30287): objectql takesisUserBanInForceandUSER_BAN_FIELDSfrom it through@objectstack/core, anduser-assigned-work.tsthrough./user-ban.js.engine.tsis in facade form onmain(Codemod split: generate the objectql engine.ts split with a deterministic script, verified byte-identical #29986), so the read lives in its facet: inpackages/objectql/src/engine-references-and-master-detail.ts,referenceExistsnow delegates to a newreferenceRow(target, id, context, extraFields), the samefindOneunderObjectQL.referenceCheckContext, which also selects the extra fields the target declares and lets a read error through. The catch stays inreferenceExists, where the read-seam baseline already records itsnullas the declared third state, and the ban path catches its own call, logs a warning and fails open.packages/objectql/src/engine.tsgains only a one-linereferenceRowstub, its slot in the facet host, and thereferenceRowandloggerslots in the reference-check host literal;check:facade-delegation-onlyandcheck:durability-log-levelare green and the baseline is untouched.Census rows the new routes need
sendOk/sendErrorand are declared tocheck:route-envelopeat zero.user-workarrives classified in the authorization conformance matrix: rowanonymous-deny-user-work(both handlers open withenforceAuth), with its booted proof inshowcase-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.REST_SERVER_REGISTRAR_FILES, the docblock floor (6), the canonical-query table (0) and theobjectQLProviderconsumer census (6 sites). The two routes aresdkrows in the route ledger.no-record-echorow in the internal-fields tripwire and anANSWERS_THE_SENTENCErow in the hook-refusal ledger: a sandboxed hook refusing a moved row reads, infailures[].message, as the hook's own sentence, and every fault shape §6 drives keeps its words withheld.user-assigned-work.tsis classifiednot-a-served-readin core's second-object read-exposure pin: the target read answers only a yes/no about the target, never a row.Tests
packages/objectql/src/engine-banned-user-reference.test.ts(10): insert refused and nothing written; the read is elevated (the caller cannot readsys_userand is refused all the same); auserfield 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 staysreference_not_found; a user read that throws refuses nothing and logs a warning.packages/core/src/security/user-assigned-work.test.ts(11): field picking, counts under the caller's context,unreadable, disabled packages, the hand-over, per-row failures, the row cap andremaining, the three refusals, the elevated target read, and the per-door failure wording. The ban predicate's own tests stay withuser-ban.ts.packages/rest/src/user-work-routes.test.ts(7) andpackages/client/src/admin-surfaces.test.ts(+2).audit-milestone-summary,audit-option-label-summary,implicit-reference-target-summary) are unchanged and green: a create that names a user still pays zero extra reads.updatewithassertEngineUpdateDispatch, refuse a logical operator rather than read it as a field, and hold alimit, socheck:engine-double-contract(ledger rows recorded with--write),check:where-matcherandcheck:objectql-double-limitstay green.Acceptance notes
ui/**.remainingtells the administrator what is left.Review round 1
Contract review FAIL (comment 6662784185) named five must-fixes; each is in this head:
findOne(referenceRow, d5d8874, moved into the references facet by the main merge ab3fc8d) instead of a secondfindonsys_user. No pin moved; the plugin-audit suite is green at this head.note:string: moved to the comment above the row (ed85181). No baseline entry.Clause-②: yes (narrowing)here and in the changeset; the BREAKING banner,patch, the ADR-0087 marker andMetadata-protocol: nonestay.rest-server.tsdocblocks:registerUserWorkEndpointsnow sits afterregisterSecurityExplainEndpoints, each under its own docblock, and the diff against main in that file is a pure addition (5856fb0).The closing line became
Part of #30205(no closing verb): the ui half is still open on the card.mainwas merged again (ab3fc8d) to clear a conflict with the #29986 codemod's fourth pass (#30463) inengine.ts; the conflict resolution had moved the probe's catch intoreferenceRow, which the read-seam rule flagged, so a5de9d1 puts it back inreferenceExists.Verification
Head
a5de9d1e6(the landing head), verified locally under the shared verify lock.Dogfood (narrowing arm):
pnpm --filter @objectstack/dogfood testafter a fullturbo build, lock VERDICT command-exit 0, Test Files 420 passed / 3 skipped (423), Tests 3500 passed / 36 skipped (3536), no failures.Suites:
@objectstack/plugin-audit71 files / 1137 tests, including the three read-cost pins;@objectstack/objectql553 files / 11034 tests; lock VERDICT command-exit 0.Gates: all 128 families
dispatch-gates.mjs --commandsderives for this diff ran, and--ranreconciles 128 / 128 at exit 0. One was red on the first run:@objectstack/spec check:generated, wherecheck:test-typecheck'stscdied without diagnostics under the default heap. Re-run withNODE_OPTIONS=--max-old-space-size=8192it exits 0 with all 14 artifacts current. That is the same heap ceiling earlier rounds hit, not a stale artifact.Targeted:
check:durability-log-level(102 read seams, 1 baselined, none stale),check:facade-delegation-only,check:engine-double-contract,check:query-options-erasure,check:adr-0087-registration,check:error-code-casing, all exit 0.Real boot, before and after (on
6e44006f9, before the main merges; the behaviour has not moved since). Run withpnpm dev -- --fresh, the showcase app, fieldshowcase_announcement.owner_id. "Before" is that head with the ban check and the route registration ablated insrcand both packages rebuilt;ablation-dist-preflightproved the markers absent fromdist/, then present again after the restore and rebuild.201201owner_id: B201(assigned to a banned user)400 VALIDATION_FAILED,fields[0].code: reference_user_banned200200(stored value not judged)GET …/users/B/assigned-work404200,showcase_announcement.owner_idcount 2404400,toUserId/reference_user_banned, nothing written404200:updated 1,failed 1(RECORD_LOCKED, the edited record sits in an approval),remaining 1Ablations (
scripts/ablation-replace.mjs, wrap mode, each restored to the HEAD blob):🤖 Generated with Claude Code
https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF