Repository navigation
spec/security: field masking is all-or-nothing — no partial masking (phone last-4, ID middle-8), and maskingRule was pruned as dead in 2026-06 #8993
Description
Activity
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actionsTriage:
needs-user-decision+domain:spec(re-introducing a declarable field key changes the spec acceptance surface; runtime enforcement rides the FieldMasker channel). Type: Feature. Manual floor: capability addition + contract-shape change — and a deliberate reversal of the 2026-06 dead-surface prune, which only the maintainer should sanction.Background
Masking today is binary (
requiredPermissionsmask-on-read;type:'secret'). No partial masking (phone last-4, ID middle-8).maskingRulewas pruned 2026-06 under ADR-0049 as declared-but-unenforced. Same bank CRM RFI as #8992; partial masking is the normal form of this control in PIPL practice.Premises (re-check before acting)
- Prune note still present:
git grep -n "maskingRule" origin/main -- packages/spec/src/data/field.zod.ts - Single enforcement channel:
git grep -rln "FieldMasker" origin/main -- packages | head
The question
Re-introduce partial masking as a runtime-first capability with a declarable rule (named presets + keep-head/keep-tail), riding the existing FieldMasker channel?
Options
- A. Accept runtime-first: FieldMasker gains partial rules; the declarable key lands only in the same PR series as its enforcement; export path honours it; closed preset enum (
phone,id_card,bank_account,email,name) + keep-head/keep-tail escape hatch. - B. UI-side (render-time) masking only — cheaper, but creates the API bypass the current design correctly avoids.
- C. Defer.
Recommendation — A
- Measured business pull: stated requirement in a live RFI, currently quoted as custom work; masked-but-recognisable values are what auditors accept and what phone-verification workflows need in the target market.
- Platform long-term coherence: one enforcement channel (runtime FieldMasker — API caller and browser user masked identically; the AI-context interceptor inherits it for free). B forks the channel and is the exact failure mode auditors probe (screen masked, CSV not).
- AI-agent error-resistance: closed preset enum over free-form format strings; the key re-enters the schema only with enforcement attached, so the prune stays honoured in spirit (declare = enforce); stable output (same input → same masked value) keeps list rendering and grouping deterministic.
- Startup scope discipline: this reverses a deliberate removal, so the burden of proof is pull — which now exists and is quantified. Scope stays at presets + head/tail; no per-role rule matrices until asked.
Four-edge block:
- Platform long-term coherence: single masking channel preserved; contract grows by one enforced key + closed enum.
- Measured business pull: PIPL-practice standard; live RFI line item currently quoted as custom work.
- AI-agent error-resistance: closed presets; declaration lands only with enforcement (no resurrected dead surface).
- Startup scope discipline: prune reversal justified only by measured pull; MVP presets only.
Related
#8992 (read audit — same RFI; natural enterprise compliance bundle). Prune record:
docs/audits/2026-06-dead-surface-disposition-plan.md.本评论来自分诊座位 Routine(session
session_01Q363J3p5yc6MaM1yY8yBcE)。
Generated by Claude Code
- Prune note still present:
Maintainer ruling (2026-08-16, recorded by PM session
session_01NYgmGheCzM6NrHZN436Cxf). Ruled together with #8992 as one strategic decision. Verbatim: 「主线二:批 A+A = 宣布进入监管行业市场(企业级合规包方向)」 — i.e. both cards' Option A approved, explicitly framed as entering the regulated-industry market with an enterprise compliance bundle direction.Ruled: Option A — partial masking as a runtime-first capability.
Scope pins (from the triage recommendation, now binding):
- Rides the existing
FieldMaskerchannel only — no second (UI-side) channel; API caller and browser user masked identically; the AI-context interceptor inherits it. - Closed preset enum (
phone,id_card,bank_account,email,name) + keep-head/keep-tail escape hatch. No per-role rule matrices until asked. - The declarable key lands only in the same PR series as its enforcement — the 2026-06 prune stays honoured in spirit (declare = enforce; no resurrected dead surface).
- Masked output stable (same input → same output); export path honours the rule.
Dispatch notes:
domain:spec(declarable key = accept-surface change) ⇒claude-fable-5tier mandatory. Pairs with #8992 as the compliance bundle; packaging split (open-runtime seam vs enterprise policy) follows the existing AI-governance precedent.Label flipped
needs-user-decision→pm:queuein the same stroke.
Generated by Claude Code
- Rides the existing
Claim: PM loop round 6
Session:session_01225pUjnCKWqxcc1PeqKFUq
Branch:claude/issue-8993-partial-masking-runtime-first
Worktree:objectstack-issue-8993(+ sibling worktrees per repo if enforcement spans one — measure first)
Domain:domain:spec
File surface: the ruled scope only —packages/spec/src/data/field.zod.ts(declarable partial-masking rule: closed preset enum + keep-head/keep-tail), the FieldMasker enforcement channel (locate on today's tree — expectedplugin-securityfamily), export-path honouring, tests, one changeset, regenerated references (field pages) (stop on breach; explain in the report)
Container & model: M (ruled feature with pinned scope),mode:subagent,model: fable— mandatory (accept-surface addition; the ruling's dispatch note mandates it)
Serial constraints cleared:⚠️ same-file churn — #9113 (field.zod.tsplaceholder) is landing right now: branch off currentorigin/mainAND re-merge after #9113 lands before finalizing; both edits touchFieldSchema, declare region-level care. File-disjoint from in-flight #9040 (driver/mongo.zod.ts). The ruling's binding scope pins (single FieldMasker channel · closed presetsphone/id_card/bank_account/email/name+ keep-head/keep-tail · declarable key lands ONLY in the same PR series as its enforcement · stable masked output · export path honours it · no per-role matrices) are on the adjudication comment above — they are 裁决, not suggestions.
Generated by Claude Code
- added 4 commits that reference this issue
on Aug 16, 2026 { "issue": 8993, "status": "done", "branch": "claude/issue-8993-partial-masking-runtime-first", "pr": "https://github.com/objectstack-ai/objectstack/pull/9128", "premise_still_valid": true, "summary": "Both adjudication premises re-verified on today's main (prune note present at field.zod.ts:989; FieldMasker in plugin-security; export streams through findData -> engine middleware, so NO export bypass exists — the PM's maskable-seam assumption held, no scope fork). Implemented the full ruled scope in one PR: FieldSchema.maskingRule (closed presets phone/id_card/bank_account/email/name + keepHead/keepTail, ADR-0122 alias + Iso850 pin, guidance tombstone flipped per the #9113 placeholder precedent, live liveness row, forms + translated form strings) and its FieldMasker enforcement in the same PR — deterministic/idempotent length-preserving masks; requiredPermissions is the unmask gate (same ADR-0066 D3 evaluation, delegator-intersected per D10); explicit permission-set denies still delete; masked-for-caller fields are non-filterable/sortable/groupable/aggregatable (403, oracle guard); masked-echo writes refused 400 VALIDATION_ERROR; getReadableFields keeps masked columns so export headers match served rows; export parity pinned screen-vs-CSV. Packaging: everything in the open runtime per the AI-governance precedent (enterprise inherits the channel, adds nothing parallel). #9113 landed mid-task and was re-merged via os-regen-merge.sh with artifacts regenerated on the merged tree; both keys verified present. One PM-visible judgment call flagged in the PR: a rule with no requiredPermissions masks every non-system caller (declare = enforce from first read) rather than sitting inert.", "tests": "Union re-run at final commit 54cda1e3b (post-#9113 merge): plugin-security 66 files/1276 tests green; spec 406 files/10781 tests green; rest 120 files/2003 tests green; typechecks green (spec incl. test layer, plugin-security, rest, platform-objects). Gates at head: check:generated 'All 13 generated artifacts are up to date'; check:liveness green (maskingRule row live, counts regenerated 846->847); check:i18n 'OK (9 packages)'; check:i18n-coverage 'none new'; check:spec-parsed-alias 'OK' (after ADR-0122 fix, 837 pinned); ratchet family green (type-check-coverage 'none above its recorded number', type-check-debt, engine-double-contract, where-matcher, query-options-erasure); nul-bytes, merge-driver, cross-package-test-inputs, changeset gates (adr-0087-registration, no-major, empty), dev-prereqs green; examples validate crm/showcase/todo exit 0. Beyond the prompt's named list, the diff-derived run (scripts/pm/dispatch-gates.mjs) added check:spec-parsed-alias (went red, fixed: z.input alias + Iso850) and check:i18n (went red, fixed: bundles regenerated + hand-translated). CI on PR #9128 is in_progress — reported at draft-PR time per contract.", "open_questions": [], "out_of_scope_findings": ["filed as #9127: security explain's field-mask layer reports binary readable/masked only — gated maskingRule fields read as fully hidden, gate-less rule fields as fully readable; should mirror computePartialMaskRules"] }Generated by Claude Code
Generated by Claude Code
ACCEPT (content) — PR #9128 (
claude/issue-8993-partial-masking-runtime-first, reviewed at head54cda1e3b). Verified against GitHub, not the report's self-description. Ready-flip waits only on CI convergence.- Every binding scope pin honored: single FieldMasker channel (no UI-side fork); closed preset enum + keepHead/keepTail, no free-form strings, no per-role matrices; declare = enforce in one PR (the 2026-06 prune's spirit kept — the key returns only with its runtime consumer, tombstone flipped per the feat(spec): declare
placeholderon FieldSchema — Option C ruling on objectui#4676 (#9019) #9113 precedent, liveness row live); deterministic/idempotent masks; export parity measured, not assumed — export streams throughfindData→ the same engine middleware, and the screen-vs-CSV byte-parity suite pins the auditors' probe. - Security-shape quality: the no-oracle fold (masked fields refuse filter/sort/group/aggregate — equality probes would reconstruct the span), the masked-echo write refusal (round-tripped
138****5678cannot silently overwrite the stored value, while privileged imports stay possible), explicit permission-set denies still win over masking, and D10 delegator intersection on the unmask gate. - Packaging per the AI-governance precedent: key, presets, AND engine all in the open runtime — a community deployment enforces a declared rule too; the enterprise bundle composes on top, nothing parallel created.
- Three judgment calls, recorded for the maintainer (all within the ruled frame, all conservative in the security direction): ① a rule WITHOUT
requiredPermissionsmasks every non-system caller — the inert-until-gated alternative would recreate the dead-declaration class the prune removed; ② masked read does not deny write (CRM workflow), made safe by the echo guard; ③ sort-by-masked-column is refused; relaxing is a deliberate follow-up if UX pull appears. - Two derivation-added gates went red and were fixed pre-CI (spec-parsed-alias: ADR-0122 alias + Iso850; i18n: bundles regenerated + hand-translated) — the [finding] check:i18n is invisible to dispatch-gates path derivation — a spec
.form.tschange moves platform-objects bundles but no derived gate list names the family #9116 gap class, caught by the dev's own re-derivation this time. feat(spec): declareplaceholderon FieldSchema — Option C ruling on objectui#4676 (#9019) #9113 mid-task churn handled viaos-regen-merge.shwith both keys verified on the merged tree. Residual filed as security explain: the field-mask layer does not report partial masking (maskingRule) — gated fields read as fully hidden, gate-less rule fields as fully readable #9127 (explain engine's binary mask reporting), not folded in.
Landing: touches regenerated artifacts — relay discipline at queueing if
mainmoves on those faces; flip ready + auto-merge at gate green.
Generated by Claude Code
- Every binding scope pin honored: single FieldMasker channel (no UI-side fork); closed preset enum + keepHead/keepTail, no free-form strings, no per-role matrices; declare = enforce in one PR (the 2026-06 prune's spirit kept — the key returns only with its runtime consumer, tombstone flipped per the feat(spec): declare
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actionsTriage sweep: added
pm:blocking(cache derived from theBlocked-by:index — #9127, the explain-engine partial-mask reporting follow-up, queues behind this card's landing). No action needed from the executing seat; the downstream card unlocks on your merge.
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026 - added a commit that references this issue
on Aug 17, 2026 - added a commit that references this issue
on Aug 18, 2026 - added a commit that references this issue
on Aug 22, 2026 - added 3 commits that reference this issue
on Aug 23, 2026 - added a commit that references this issue
on Oct 7, 2026
Summary
Field-level masking today has exactly two states: fully readable, or fully masked.
requiredPermissions(ADR-0066 D3) — "mask on read, deny on write", an all-or-nothing gate.type: 'secret'— encrypted at rest, opaque ref on the row, masked on read. Correct for credentials, wrong shape for business PII.There is no way to declare partial masking: show the last 4 digits of a phone number, mask the middle 8 of a national ID, keep the surname and mask the given name, show the last 4 of a bank account.
History — this is a re-introduction, not a new idea
maskingRuleexisted as a declarable key and was deliberately removed in the 2026-06 dead-surface prune (packages/spec/src/data/field.zod.ts):That prune was right under ADR-0049 enforce-or-remove — the key promised protection the runtime never delivered. This issue is therefore explicitly a request for the runtime capability first, with the metadata key re-introduced only as its authoring surface. Re-adding a declarable
maskingRulewithout enforcement would just recreate the dead surface that was removed.Why it matters
Found while scoping a bank CRM RFI (VTB Shanghai SME), where "敏感字段脱敏" (sensitive-field masking) is a stated security requirement and had to be quoted as custom work.
Partial masking is the normal form of the control in Chinese PIPL practice — regulators and internal audit expect masked-but-recognisable values so staff can still verify identity over the phone without seeing the full number. All-or-nothing masking fails both ways: fully visible leaks, fully hidden breaks the workflow.
What's already right
The enforcement channel is sound and should be reused as-is: masking is applied by the runtime
FieldMasker, not by the UI, so an API caller is masked identically to a browser user — there is no bypass path. Partial masking should ride that same channel rather than introduce a second one. (It also already applies before AI tool output reaches model context, per the enterprise AI-governance interceptor, so a partial rule would inherit that for free.)Suggested shape
phone,id_card,bank_account,email,name) plus a keep-head/keep-tail form for the long tail.FieldMaskeralongside the existing all-or-nothing path, gated by the same permission evaluation.Verified against
0f539bd—packages/spec/src/data/field.zod.ts(lines ~765–780 prune note, ~838–854requiredPermissions/ackPlaintextMasking, ~988secret).