Skip to content

fix(platform-objects): withhold the sign-in phone fields from org peers in the user directory - #27431

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/harden-user-directory-fields
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/harden-user-directory-fields

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under skills/** teaches the sys_user phone fields or the user directory's field security (git grep -i -c 'phone_number\|phoneNumber' -- 'skills/**': zero hits, exit 1; control git grep -c sys_user -- 'skills/**': hits), so no skill text describes the served set this narrows.

维护者速读

  • 改了什么:sys_user.phone_number / phone_number_verified 从 Account 字段组移入 Admin 字段组;普通成员读用户目录时不再拿到其他用户的登录手机号。
  • 为什么改:手机号是 better-auth 的登录标识,不是通讯录数据;Admin 组已由内置权限集做字段级只读屏蔽(成员屏蔽、管理员保留),沿用同一机制,不新增规则。
  • 风险与回滚:这是一次接受集收窄(BREAKING,按 patch 发布):成员按这两个字段过滤、排序、分组、聚合会被 403 拒绝,$search 静默不再扫手机号列,成员读自己那行也看不到手机号(本人手机号从登录会话读取);回滚 = 把两个字段的 group 改回 Account。
  • 先看:packages/platform-objects/src/identity/sys-user.object.ts(两个字段的 group);.changeset/user-directory-sign-in-phone-fields.md(BREAKING 横幅与 FROM/TO);packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts(成员按姓名搜同事命中、按手机号搜无结果)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-security exec vitest run src/identity-admin-field-group.test.ts,预期 19 passed。
  • 需要你做的:无,席位落地(本人行问题已在 6497499302 裁决「接受」)。

What changes

The user directory no longer returns another user's sign-in phone number to non-admin readers. Security hardening, declared as an accept-set narrowing.

  • sys_user.phone_number and sys_user.phone_number_verified (the better-auth phone sign-in identifier and its verified flag) move from the Account field group to the Admin field group.
  • The Admin group is already the declaration the shipped permission sets read (identityAdminFieldSecurity in plugin-security's default-permission-sets.ts): member_default, viewer_readonly and external_default mark every group field readable: false, editable: false; admin_full_access, organization_admin and its no-bypass variant keep it. No new rule, list or field attribute: the server's existing field-level security (field masker, predicate guard, activity redaction, metadata-plane masking) does the work, so the Console needs no change.
  • Positions that narrow for a non-admin member (the changeset carries the full FROM / TO under its **BREAKING** (an accept-set narrowing) banner):
    • row position: both fields are withheld from every sys_user row served, the member's own row included (field-level read is row-blind, ADR-0066);
    • filter, sort, group-by and aggregate positions naming either field are refused 403 PERMISSION_DENIED;
    • $search: the default search set is narrowed silently, so a user picker keeps answering on name and email and a search for a colleague's number returns no row.
  • banned / ban_reason / ban_expires: reviewed, no change. banned stays served to org peers by an earlier ruling (directory status every user picker filters on); ban_reason and ban_expires are already in the Admin group and already withheld.
  • Changeset: @objectstack/platform-objects patch (a non-protocol narrowing ships patch and keeps its BREAKING banner); exactly one ADR-0087 disposition, not-required (no-migration-prescription): no metadata key, spelling, export or stored shape moves.

Acceptance notes

  • Own row (ruled). Field-level read security is row-blind (ADR-0066, 2026-10-26 note: readable stays row-blind; when beside readable: false is refused at parse). So a member's OWN row on the generic data API is withheld the phone fields too, exactly as for every other Admin-group field. The maintainer ruled this accepted in comment 6497499302 (「接受」): ship as is, the own number stays reachable through the auth session, no row-aware read vote. better-auth's phone-number plugin declares phoneNumber / phoneNumberVerified with returned: true, so the session user carries them when the plugin is enabled.
  • System-context readers (sign-in, SMS delivery, admin import, the auth adapter) are unaffected.
  • A deployment that wants members to read colleagues' phone numbers grants a set naming the two fields readable: true (field grants merge most-permissively).
  • Rework round (contract review 6501154665, FAIL on the declaration alone): this head adds the (narrowing) arm, the BREAKING banner with FROM / TO and the ADR-0087 marker to the changeset, the Skills-docs: line here, and the should-fix $search cases at the HTTP door. No package source changed in this round. Rework run dispatched from PM session session_017xRBcVy4VXmkQjn2MGeaWk.

Tests

  • packages/plugins/plugin-security/src/identity-admin-field-group.test.ts: the two phone fields are declared inside the group; a member (member_default) is withheld both and the field masker strips them from a colleague row; each admin set keeps both and the masker serves them.
  • packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts (real showcase boot, HTTP door): the colleague fixture carries a synthetic phone; a member's directory read projecting the phone fields is served the colleague's id, name and email without them; the org owner and the platform admin read the stored values. New this round: a member $search on sys_user by the colleague's name is served 200 with the colleague listed; a member $search by the colleague's synthetic number is served 200 with no row; the platform admin's $search by the same number lists the colleague (the control that the term matches when the column is scanned). The existing generic group pins (member reads colleague, list door, activity door, member own row, filter refusal) cover the phone fields too.

Verification

All readings below were taken on head a77f981d (the final head; git rev-parse --short HEAD = a77f981d). No package source changed in this round; the package-suite readings of the previous round on a195fe983 (unchanged sources) stand for platform-objects and plugin-security.

Declaration gates (this round's must-fix)
  • pnpm check:adr-0087-registration: exit 0. ✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. — .changeset/user-directory-sign-in-phone-fields.md [BREAKING+clause-②-narrowing] not-required (no-migration-prescription). Self-test 705 assertions.
  • node scripts/check-changeset-no-major.mjs (local, no event): exit 0, no major; level axis NOT APPLICABLE (no payload, by construction).
  • node scripts/check-changeset-no-major.mjs --event EVENT with a pull_request payload built from this staged body: exit 0. ✓ LEVEL AXIS: this PR declares clause-② no (narrowing) and metadata-protocol none ... a non-protocol change at patch. Control: the same run over a payload built from the previous body reads direction arm: none declared — the arm is read from the body, not assumed.
Dogfood (the narrowing owes the battery)
  • Closure build pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build: lock VERDICT command-exit 0.
  • Touched file identity-admin-fields-org-peer.dogfood.test.ts: Test Files 1 passed, Tests 18 passed (15 before this round, plus the three $search cases). Lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/dogfood typecheck: exit 0.
  • Full battery pnpm --filter @objectstack/dogfood test (no shard, no path) on a77f981d: Test Files 348 passed, 3 skipped (351); Tests 2812 passed, 20 skipped (2832). Lock VERDICT command-exit 0. The CI Dogfood Regression Gate shards were skipped by their selector on this head, so this local run is the battery's evidence.
Ablation of the new search pins (one-shot, not kept)

Mutation via scripts/ablation-replace.mjs: phone_number's group: 'Admin' replaced with a marker group (anchor 1 → 0, blob 67491e481b64 → c9c848e94377), platform-objects rebuilt, marker present in 4 built files per ablation-dist-preflight.mjs.

  • Dogfood file: 2 failed / 16 passed — the member projection read and the member search by the colleague's number (it found the colleague). The member name search and the admin control stayed green, as expected.
  • Restore leg: blob equal to HEAD (67491e481b64), git diff HEAD empty, whole-tree git status --porcelain clean, rebuilt, marker absent from all 66 built files (--absent); file green again, 18/18.
Derived gate families

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on a77f981d: 65 commands; 64 exit 0, 1 exit 3. --ran reconciliation: 65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN. NOT MEASURED: check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET (seven packages outside this diff's closure have no dist/ in this worktree); CI carries it in Lint & Repo Gates (success on this head). node scripts/check-nul-bytes.mjs exit 0. Repo-wide scans (pnpm lint, test:repo) are left to CI.

CI on `a77f981d`

Every non-skipped check-run concluded success, the ten required contexts among them: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Test UI; Lint UI, Type Check UI, Build & E2E UI and Live E2E UI were skipped by their path filter. The earlier Lint & Repo Gates red on 99bfe6d4 was its lint lanes concluding cancelled when the push of a77f981d superseded that run (run 40904302735 concluded cancelled), not a gate verdict.


Generated by Claude Code

Body refreshed by the PM seat 2026-10-29T16:49Z from os-dev report 6502824437.

🤖 Generated with Claude Code

https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk

…rs in the user directory

phone_number and phone_number_verified move into the sys_user Admin field
group, which the shipped non-admin permission sets mark readable: false and
the admin sets keep. An org member reading the user directory is served a
colleague's name and email, no longer their sign-in phone number.

Claude-Session: https://claude.ai/code/session_017xRBcUoS2G4jgqRskpExS7
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — pages documenting those are invisible to this run
  • 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 — 18 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 a552e8699255db754446256aabac02c81ebd73a8 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json a552e8699255db754446256aabac02c81ebd73a8

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Ruled: maintainer, in the PM session session_017xRBcVy4VXmkQjn2MGeaWk, 2026-10-29 · 2026-10-29T13:06Z

Question (open decision in the PR body): accept that, because field-level read is row-blind (ADR-0066), a member also loses their OWN phone fields on the generic data API. Answer, verbatim option chosen: 「接受」 — ship as is; the own number stays reachable through the auth session. A row-aware read vote is not pursued.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a195fe983106c961a97e302a7511fa7e52dff007
Local-runs: none
Scope: packages/platform-objects/src/identity/sys-user.object.ts, packages/plugins/plugin-security/src/objects/default-permission-sets.ts, packages/plugins/plugin-security/src/identity-admin-field-group.test.ts, packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts + packages/plugins/plugin-security/src/predicate-guard.ts (assertReadableQueryFields, collectQueryFields), packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts (steps 2.5b and 2.9), packages/plugins/plugin-security/src/security-plugin.ts (getQueryableFields, resolveProjectionFieldMask), packages/plugins/plugin-security/src/field-masker.ts, packages/objectql/src/engine.ts (the search expansion over queryableFields), packages/objectql/src/search-filter.ts, packages/metadata-protocol/src/protocol-record-reads.ts (the $search door), packages/plugins/plugin-auth/src/auth-schema-config.ts (AUTH_PHONE_NUMBER_USER_FIELDS), packages/client/src/index.ts (the session user's phoneNumber fields), packages/platform-objects/src/pages/sys-user.page.ts, docs/adr/0066-unified-authorization-model.md (⑧ and the 2026-10-26 / 2026-10-28 notes), packages/platform-objects/CHANGELOG.md (the #21237 entry), packages/objectql/CHANGELOG.md (the #22646 entry), scripts/check-changeset-no-major.mjs (the declaration prompt), ruling comment 6497499302.

Face: narrowing. The diff touches no packages/spec/src/** non-test path and no governed path; the PR body declares Clause-②: no and the maintainer dispatched this review on the disclosure ruling. On this face the scope floor is every path of the diff outside the prose faces; the changeset is the lane seat's at ACCEPT and is read here only for ②.

Security-hardening ruling: this record names positions and refusals; it carries no reproduction.

① Derived judgments

The accept set and the served surface the diff changes, read off the code on this head:

  1. Two fields change group: sys_user.phone_number and sys_user.phone_number_verified, Account → Admin. Nothing else on the object moves (git diff: the two group: lines and comments). identityAdminGroupFields() reads the group off SysUser.fields at module load, so member_default, viewer_readonly and external_default now carry sys_user.phone_number / sys_user.phone_number_verified as { readable: false, editable: false } and the three admin sets carry { readable: true, editable: true }. No list, rule or field attribute is added. Correct, and the mechanism is the one plugin-security: an org member reading a colleague's sys_user row is served the Admin-group fields (last_login_ip/at, failed_login_count, locked_until, ban_*, phone_number) — and through sys_activity, their sign-in history #21237 settled.
  2. Row position, every generic read door. The field masker (step 4) strips both fields from every row served to a caller resolving no set that names them readable: direct read, list read, the record-id read, and the activity ledger's redaction, which serves the same projection. Pinned at the unit level (masker over a colleague row) and at the HTTP door (member projection read served id, name, email without either field; owner and platform admin served the stored values). Correct.
  3. Own row. readable is row-blind (ADR-0066 ⑧, 2026-10-26 note: when beside readable: false is refused at parse; 2026-10-28 note: readable stays row-blind), so the member's own sys_user row on the generic data API loses the two fields too. The maintainer ruled this accepted (6497499302, 「接受」). The holder's own number stays reachable through auth context: AUTH_PHONE_NUMBER_USER_FIELDS maps better-auth's phoneNumber / phoneNumberVerified to the two columns, and the client's session-user type carries phoneNumber / phoneNumberVerified under the phoneNumber plugin. The existing own-row dogfood pin reads the group off the declaration, so it now covers the phone fields without an edit. Correct as ruled.
  4. Filter, sort, group-by and aggregate positions. Step 2.9 (assertReadableQueryFields over the caller's own AST, before RLS injection) and step 2.5b (aggregate inputs) refuse 403 PERMISSION_DENIED (field_predicate_denied) a member's query naming either field in where, having, orderBy, groupBy or aggregations[], at every verb that carries an AST, bulk writes' own predicate included. On main the same member's filter on phone_number was admitted and evaluated. This is a refused-now, admitted-before query shape, the exact class of fix(objectql): extend the credential-column guard from aggregates to filter and sort keys #27477 / security(data): the data door's filter and group-by positions do not honour a field's internal: true the way its row read does — detail withheld pending maintainer #22646. Fail-closed and correct; it is the subject of ②.
  5. $search scan set. phone_number is searchable: true (one of three on the object, with name and email), so it sits in the auto-default search set. The engine's search expansion asks the security service's getQueryableFields for the caller and hands the answer to expandSearchToFilter as queryableFields; getQueryableFields filters the schema's field universe by the step-2.9 guard map, so for a member the phone column is dropped from the set rather than refused, and the protocol's $search door narrows the same way. A member's user-picker search therefore keeps answering on name and email. Read and judged correct against the cited code; no case in this diff pins it at the door (should-fix 1).
  6. Writes. Both fields are readonly: true (written through better-auth, not CRUD), so the field-level write gate's new editable: false for members changes no reachable write; the admin sets' editable: true is the state of a field no set names. Correct.
  7. System- and auth-context readers. Sign-in by phone, SMS delivery, the admin import's upsert match (matchFields: ['phone_number'], run under an administrator's grant), admin-user-endpoints' phone handling and the better-auth adapter read under system or auth context, which no permission set reaches; the import panel's phone_number column stays an admin surface. Unaffected. Correct.
  8. Forms and pages. Wherever a form lays fields out by group, both fields now render in the Admin section; the Account group is left holding banned alone (line 863 is its only remaining member), which is cosmetic. sys-user.page.ts keeps phone_number in the admin page's highlights and hides it from the details grid; a non-admin reaching that page sees an empty chip, which is the masker's answer and not a fault. Correct.
  9. banned / ban_reason / ban_expires: no change. banned stays outside the group by the plugin-security: an org member reading a colleague's sys_user row is served the Admin-group fields (last_login_ip/at, failed_login_count, locked_until, ban_*, phone_number) — and through sys_activity, their sign-in history #21237 ruling (directory status every picker filters on); the other two were already inside. The unit pin that PEER_DIRECTORY_FIELDS are outside the group is unchanged. Correct.
  10. Public surface. No export, type or route changes; @objectstack/platform-objects publishes the moved group values in its object declaration, and the generated translation files are untouched (the field labels and help text do not move). Correct.
  11. Grant-back path. A deployment that wants members to read colleagues' numbers names the two fields readable: true in a set; field grants merge most-permissively (PermissionEvaluator.getFieldPermissions), so one such set out-votes the baseline's withhold. The body's statement is right.

② Semver level

Declared: Clause-②: no, Metadata-protocol: none, @objectstack/platform-objects: patch.

The arm is missing. A query shape every non-admin member could write and have evaluated on main (a $filter, $orderby, group-by or aggregate naming sys_user.phone_number or sys_user.phone_number_verified) is now refused 403 at the generic data door, and a response shape every member was served (the two columns on a colleague's and on their own row) is withheld. That is an accept-set narrowing of a served runtime surface, the class check-changeset-no-major.mjs describes at its own prompt ("add (narrowing) if it narrows one, a BREAKING change, whatever its level"). The precedents are on this face:

Two consequences of the bare no are measurable on this head: node scripts/pm/check-owed-reviews.mjs lists neither this PR nor #27440 among the PRs owing a contract review (the face reader sees no arm and no path hit), so the review the maintainer ordered is invisible to the sweep; and check-adr-0087-registration.mjs reads no breaking signal, so the disposition the changeset owes is never asked for. A no without the arm is the auditable false declaration landing-operations.md names.

What is right and stays: Metadata-protocol: none (no key, value or metadata type an author writes changes; the group key keeps its shape and vocabulary) and the patch level (ruling A: a non-protocol narrowing ships patch and keeps its BREAKING carriers). The changeset's package list is right: plugin-security's hunk is comment-only and the seeded sets read the group at runtime from platform-objects, so the behaviour ships with the package the diff moves. The arm owes the PR body a Skills-docs: line: no skills/** file teaches the user directory or the phone fields (skills/objectstack-data/rules/field-types.md names only the phone field type), so none — REASON is the admissible value, read at ACCEPT.

③ Boundary flags

④ Design judgment

On the narrowing face, against the published spec on main: the change takes no declared capability away at the consumer and makes an existing posture real. ADR-0066 ⑧ records FLS as block-list-shaped and names a sensitive field on a public object a review smell; the identity object's Admin group is the one declaration the shipped sets read for exactly that, and #21237 settled that a field belongs there when it is account-administration data rather than directory data. A sign-in identifier is the former: it is unique per user, written only through better-auth, and read by the platform under system or auth context. Moving the two fields into the group is the minimal change the mechanism admits: no second rule, no field attribute, no Console change, and the grant-back is the permission set's existing fields entry. The one real cost, the row-blind own row, was put to the maintainer and ruled. Judged per hits file:

  • sys-user.object.ts: the two group values and a comment that cites the declaration's reader and the ADR-0066 note; holds.
  • default-permission-sets.ts: comment-only, and the comment now states the $search behaviour correctly (narrowing, not refusal, ① item 5); holds.
  • identity-admin-field-group.test.ts: pins membership in the group, the member composition, the masker over a colleague row, and each admin set's keep; the masker pin is a real behavioural assertion over FieldMasker.maskResults; holds.
  • identity-admin-fields-org-peer.dogfood.test.ts: the colleague fixture carries a synthetic number in the reserved range, the member projection read is pinned by absence of the field on a served row (with id, name, email present as the control that the row is served), and owner and admin are pinned to the stored values; holds. Ablation evidence in the body (group marker, 5 unit + 1 dogfood red, restore proven) is consistent with the pins read here; not re-run.

Must-fix

  1. Declaration: in the changeset body and the PR body, Clause-②: no (narrowing); keep Metadata-protocol: none and patch; add the **BREAKING** (an accept-set narrowing) banner with a FROM / TO paragraph (the security(data): the data door's filter and group-by positions do not honour a field's internal: true the way its row read does — detail withheld pending maintainer #22646 entry is the template; the plugin-security: an org member reading a colleague's sys_user row is served the Admin-group fields (last_login_ip/at, failed_login_count, locked_until, ban_*, phone_number) — and through sys_activity, their sign-in history #21237 entry's migration sentence is the content: a member's query naming either field is refused, the two columns leave a member's served row, the own row included, and the remedy is a set naming them readable: true); exactly one ADR-0087 HTML-comment marker in the changeset body, category not-required (no-migration-prescription), stating that no metadata key, spelling, export or stored shape moves and that what narrows is the shipped non-admin sets' field read on two columns; and a Skills-docs: line in the PR body (② names the admissible value). Then re-run pnpm check:adr-0087-registration and check-changeset-no-major locally.

Should-fix

  1. Add one dogfood case at the HTTP door: the member's $search on sys_user for the colleague's name is served 200 with the colleague listed, and a $search for the colleague's synthetic number lists no row — the picker claim in the body and in the default-permission-sets.ts comment is otherwise pinned nowhere (① item 5).
  2. Changeset FROM / TO: name the refused positions in full (filter, sort, group-by, aggregate; the $search set narrowed silently) so the entry reads like the security(data): the data door's filter and group-by positions do not honour a field's internal: true the way its row read does — detail withheld pending maintainer #22646 one a client greps.

Implemented-by: claude/harden-user-directory-fields
Reviewed-by: session_017xRBcVy4VXmkQjn2MGeaWk

VERDICT: FAIL

The withholding is complete at every position the PR claims, the own-row cost is ruled, and the mechanism is the settled one; the FAIL is the declaration alone (must-fix 1). A head that carries the corrected declaration and nothing else owes a delta review by this reviewer, not a full round.

@github-actions github-actions Bot added size/m and removed size/s labels Oct 29, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk
os-dev-report

{
  "issue": null,
  "status": "done",
  "branch": "claude/harden-user-directory-fields",
  "pr": "https://github.com/objectstack-ai/objectstack/pull/27431",
  "session": "session_017xRBcVy4VXmkQjn2MGeaWk",
  "premise_still_valid": true,
  "summary": "Rework of contract review 6501154665 (FAIL on the declaration alone). Changeset now declares `Clause-②: no (narrowing)`, keeps `Metadata-protocol: none` and `patch`, carries the `**BREAKING** (an accept-set narrowing)` banner with a FROM / TO that names every narrowed position (row withheld incl. own row; filter, sort, group-by, aggregate refused 403 PERMISSION_DENIED; default `$search` set narrowed silently; writes unchanged) and exactly one ADR-0087 marker `not-required (no-migration-prescription)`. Should-fix: three dogfood cases at the HTTP door (member `$search` by colleague name -> 200 with colleague; by colleague synthetic phone -> 200, no row; platform admin same phone search -> colleague, the control). Full replacement PR body staged, not PATCHed: /tmp/claude-0/-home-user-objectstack/bdda939c-7424-5594-af38-0e6fe5d7eeed/scratchpad/pr-27431/pr-body.md (declaration lines + `Skills-docs: none — REASON`, 维护者速读 kept with 需要你做的 = 无,席位落地 citing ruling 6497499302 「接受」, verification readings on a77f981d). The PM's `Lint & Repo Gates` red on 99bfe6d4 was the lint lanes concluding `cancelled` (run 40904302735 conclusion cancelled) because the push of a77f981d superseded that run; no fix was needed, and on a77f981d the same check is success. Report comment: post-stamped requires the lane/account/session provenance line as the first non-empty line, so `os-dev-report` sits alone on the line right after it (a conflict between the dev contract's first-line rule and the write tool, named here, not chosen silently).",
  "tests": "Head a77f981d. Closure build `pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build` lock VERDICT command-exit 0. `pnpm --filter @objectstack/dogfood exec vitest run test/identity-admin-fields-org-peer.dogfood.test.ts`: Test Files 1 passed, Tests 18 passed (was 15), VERDICT command-exit 0. `pnpm --filter @objectstack/dogfood typecheck` exit 0. platform-objects / plugin-security sources unchanged this round (previous round's suites on a195fe983 stand; CI Test Core green on a77f981d). Ablation (one-shot, not kept): ablation-replace on phone_number `group: 'Admin'` -> marker group, anchor 1->0, blob 67491e481b64->c9c848e94377, platform-objects rebuilt, preflight marker present in 4 dist files; file 2 failed / 16 passed (member projection read; member phone search found the colleague); restore leg blob == HEAD 67491e481b64, git diff HEAD empty, whole-tree status clean, rebuilt, preflight --absent over 66 files, file 18/18.",
  "gates": "Local: `pnpm check:adr-0087-registration` exit 0 (1 declared-breaking changeset, [BREAKING+clause-②-narrowing] not-required (no-migration-prescription); self-test 705). `node scripts/check-changeset-no-major.mjs` exit 0 (no major; level axis NOT APPLICABLE locally). `--event` from the staged body: exit 0, LEVEL AXIS declares `no (narrowing)` / `none`, patch; control over the old body reads `direction arm: none declared`. dispatch-gates `--repo objectstack-ai/objectstack --commands` on a77f981d: 65 commands, 64 exit 0, 1 exit 3; `--ran`: 65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN. NOT MEASURED: check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET (7 packages outside the closure have no dist/); CI carries it in Lint & Repo Gates (success). check-nul-bytes exit 0. CI check-runs on a77f981d: every non-skipped run success (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Test UI, Check Changeset, Dogfood Regression Gate, PR-body guards, Spec property liveness and all lint/type-check lanes); Lint UI, Type Check UI, Build & E2E UI, Live E2E UI and the Dogfood shards skipped by selector. Check Changeset will re-read the body on the seat's `edited` write.",
  "dogfood_battery": "a77f981d · os-verify-lock VERDICT command-exit 0 · Test Files 348 passed / 3 skipped (351) · Tests 2812 passed / 20 skipped (2832) · no failures",
  "mcp_calls": "0",
  "api_writes": "1 — POST /repos/objectstack-ai/objectstack/issues/27431/comments (this os-dev-report, via scripts/pm/post-stamped.mjs); plus 3 git pushes to claude/harden-user-directory-fields (99bfe6d4, a77f981d; not REST). PR body not PATCHed (staged).",
  "open_questions": [],
  "out_of_scope_findings": [
    "carrier: none · noted, not filed: `check-changeset-no-major.mjs` prints `direction arm: narrowing — a BREAKING change; during the launch window it ships minor` on a passing patch-level narrowing, which reads against ruling A (a non-protocol narrowing ships patch); prose only, the verdict is right. Dedupe words: changeset-no-major direction arm launch window minor wording"
  ]
}

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a77f981d835a424b30360744bb07b49973d84fe8
Local-runs: none
Scope: packages/platform-objects/src/identity/sys-user.object.ts, packages/plugins/plugin-security/src/objects/default-permission-sets.ts, packages/plugins/plugin-security/src/identity-admin-field-group.test.ts, packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts + the cited code named in record 6501154665 (unchanged on this head), .changeset/user-directory-sign-in-phone-fields.md (read for ②), the PR body as rewritten 2026-10-29T16:49Z, os-dev report 6502824437, scripts/check-changeset-no-major.mjs and scripts/check-adr-0087-registration.mjs (the readers of the declaration), packages/objectql/CHANGELOG.md (the #22646 entry).

Face: narrowing, now declared. Delta review of record 6501154665 (FAIL on the declaration alone, head a195fe983). The net delta since that head is two commits, 99bfe6d4 (changeset) and a77f981d (dogfood test); git diff a195fe983..a77f981d touches no package source, so every ① and ④ judgment of the earlier record stands by reference and is not re-rendered here; this record judges the delta and the body.

Security-hardening ruling: this record names positions and refusals; it carries no reproduction.

① Derived judgments

  1. No accept-set or public-surface change in the delta. The two group values, the seeded sets and the masker/guard positions are byte-identical to a195fe983 (sys-user.object.ts, default-permission-sets.ts, the unit test unchanged). The judgments of 6501154665 ① items 1–11 hold on this head.
  2. The three new dogfood cases pin what the earlier record found unpinned (its should-fix 1). At the HTTP door, as the member: $search for the colleague's display name (Org Peer colleague, the name the fixture signs up with) is served 200 with the colleague's id; $search for the colleague's synthetic number (digits without the leading +) is served 200 with no row; and the platform admin's $search for the same digits lists the colleague, which is the control that the term matches when the column is scanned. That is exactly the narrowed-not-refused behaviour read off getQueryableFields → expandSearchToFilter({ queryableFields }) in the earlier record; the ablation in the body (marker group on phone_number: the member phone search found the colleague, the name search and the admin control stayed green) is consistent with those pins and was not re-run. Correct.
  3. The fixture's own control is intact. The armedWhen guards, the group read off the declaration, and the earlier fifteen cases are unchanged; the header comment adds the new persona line. Correct.

② Semver level

Declared on the changeset and the PR body, both line-initial: Clause-②: no (narrowing), Metadata-protocol: none; @objectstack/platform-objects: patch; PR body Skills-docs: none — REASON with the measured git grep reading (zero hits for the phone fields under skills/**, a sys_user control that hits). The changeset carries the **BREAKING** (an accept-set narrowing) banner, a FROM / TO that names every narrowed position (row withheld including the own row; filter, sort, group-by, aggregate refused 403 PERMISSION_DENIED at every verb carrying a query; the default $search set narrowed silently; writes unchanged; admins and system/auth-context readers unaffected; the grant-back and the client-side remedy), and exactly one ADR-0087 HTML-comment marker in not-required (no-migration-prescription) whose text states that no key, spelling, export or stored shape moves and that what narrows is the shipped non-admin sets' field read on two columns. This is the declaration must-fix 1 asked for, in the #22646 shape; the level stays patch under ruling A and Metadata-protocol: none stays right. Both readers agree on the head: Check Changeset concluded success on a77f981d and Lint & Repo Gates (which carries check:adr-0087-registration) concluded success; the dev's local readings (adr-0087-registration exit 0 naming this changeset as [BREAKING+clause-②-narrowing], the no-major gate's level axis reading no (narrowing) / none at patch from the staged body, with the old body as the control reading none declared) are consistent with those conclusions. The should-fix 2 of the earlier record (every refused position named in the FROM / TO) is met.

③ Boundary flags

  • (a) Own row: ruled 6497499302 (「接受」); the body now cites the ruling under 需要你做的 and in the acceptance notes. Closed.
  • (b) Body: the three declaration lines are the first lines after the signature line, the 维护者速读 states the narrowing in its risk line, "What changes" lists the narrowed positions and points at the changeset for the FROM / TO, Tests names the three new cases, and Verification names a77f981d as the head of every reading and says which earlier readings stand on unchanged sources. The body describes the tree. No drift.
  • (c) open_questions: none (the report's array is empty). The report's one out-of-scope note (the no-major gate's prose saying a narrowing "ships minor" on a passing patch-level narrowing) is a wording observation on a tool outside this diff, carrier none, noted here and not a finding on this PR.
  • (d) Checks on a77f981d at review time: every concluded non-skipped run is success, the required contexts among them (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Test UI; Lint UI, Type Check UI, Build & E2E UI, Live E2E UI skipped by the path filter); one second Check Changeset run, the re-read triggered by the body edit, was still in_progress. The landing pre-check (全绿) is the seat's read at landing, not this record's; the earlier Lint & Repo Gates red on 99bfe6d4 was that run's lanes concluding cancelled when a77f981d superseded it, not a verdict. Author is objectstack-fleet[bot]; no login note owed.
  • (e) The Dogfood Regression Gate shards were skipped by their selector on this head, so the narrowing's battery evidence is the dev's full local run on a77f981d (348 files passed / 3 skipped, 2812 tests passed / 20 skipped, lock VERDICT exit 0), as the (narrowing) checklist line requires; read, not re-run.

④ Design judgment

Unchanged from 6501154665 on the narrowing itself: the change takes no declared capability away at the consumer and makes an existing posture (ADR-0066 ⑧, the #21237 Admin-group declaration) real for a sign-in identifier, through the one mechanism that already exists, with the own-row cost ruled. On the delta: the changeset now says what the tree does, in the entry shape a client greps for, and the dogfood file now pins the one claim the earlier record found pinned nowhere, with a control that proves the empty answer is the narrowing and not a fixture that never stored the number. Nothing in the delta can regress a package consumer. No must-fix; no should-fix outstanding.

Implemented-by: claude/harden-user-directory-fields
Reviewed-by: session_017xRBcVy4VXmkQjn2MGeaWk

VERDICT: PASS

Delta review complete: the declaration is corrected in the #22646 shape, the $search claim is pinned at the door with a control, no package source moved, and the body describes the head. The seat reads the second Check Changeset run's conclusion before landing.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

ACCEPT → head a77f981d835a424b30360744bb07b49973d84fe8 · 2026-10-29T16:53Z

维护者速读

  • 改了什么:sys_user.phone_number / phone_number_verified 从 Account 字段组移入 Admin 字段组,普通成员读用户目录时不再拿到其他用户的登录手机号。
  • 为什么改:手机号是登录标识,不是通讯录数据。Admin 组已由内置权限集做字段级屏蔽,这里沿用同一机制,不新增规则。
  • 风险与回滚:属于 BREAKING 收窄,按 patch 发布。成员按这两个字段过滤、排序、分组、聚合会返回 403;$search 不再扫手机号列,且没有提示;成员读自己那行也看不到手机号。回滚就是把 group 改回 Account。
  • 先看:packages/platform-objects/src/identity/sys-user.object.ts、.changeset/user-directory-sign-in-phone-fields.md。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/identity-admin-fields-org-peer.dogfood.test.ts,预期 18 个用例全过。
  • 需要你做的:无。本人那一行的处理已按裁决「接受」(评论 6497499302)。

席位意见:收。在本 head 上,增量契约复核 PASS(6502933103,判的是 6501154665 之后的增量);dogfood 全套 348 个文件、0 失败。

席位核对

  1. 形态:draft、目标 main;按披露裁决不建卡,正文无关闭关键词。
  2. 前提实测:契约复核 6501154665 和 6502933103 都读过代码;增量只改了 changeset 和 dogfood 测试。
  3. 门禁:本 head 共 37 个 success、16 个 skipped、0 失败;Lint & Repo Gates、TypeScript Type Check 和 Check Changeset(两次运行)都是 success。
  4. 接受的偏差:无。
  5. 范围外去向:无。Skills-docs: none — REASON 有实测的 grep 结果支撑。
  6. 落地:不属于受管面,148 行,低于 3000 行上限 → pr_ready + automerge_enable。

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 16:54
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit ac9c2f0 Oct 29, 2026
54 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/harden-user-directory-fields branch October 29, 2026 17:56
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 size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant