Repository navigation
Description
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
maskingRule existed as a declarable key and was deliberately removed in the 2026-06 dead-surface prune (packages/spec/src/data/field.zod.ts):
// Pruned 2026-06 (dead in both layers — aspirational governance with no runtime
// consumer; encryption/masking implied at-rest protection that never happened —
// the real channel is type:'secret'). See
// docs/audits/2026-06-dead-surface-disposition-plan.md (P0/P2 field prune):
// encryptionConfig, maskingRule, auditTrail, cached, dataQuality.
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 maskingRule without 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
- A declarable rule on the field — named presets (
phone,id_card,bank_account,email,name) plus a keep-head/keep-tail form for the long tail. - Applied in
FieldMaskeralongside the existing all-or-nothing path, gated by the same permission evaluation. - Masked value must be stable (same input → same output) so it doesn't break list rendering or grouping.
- Export path must honour it — masking that applies on screen but not in CSV is the failure mode auditors look for.
Verified against
0f539bd — packages/spec/src/data/field.zod.ts (lines ~765–780 prune note, ~838–854 requiredPermissions / ackPlaintextMasking, ~988 secret).
Activity
os-project-manager commentedon Aug 16, 2026
Triage:
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)
git grep -n "maskingRule" origin/main -- packages/spec/src/data/field.zod.tsgit grep -rln "FieldMasker" origin/main -- packages | headThe 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
phone,id_card,bank_account,email,name) + keep-head/keep-tail escape hatch.Recommendation — A
Four-edge block:
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
os-zhuang commentedon Aug 16, 2026
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):
FieldMaskerchannel only — no second (UI-side) channel; API caller and browser user masked identically; the AI-context interceptor inherits it.phone,id_card,bank_account,email,name) + keep-head/keep-tail escape hatch. No per-role rule matrices until asked.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
os-zhuang commentedon Aug 16, 2026
Claim: PM loop round 6⚠️ same-file churn — #9113 (
Session:
session_01225pUjnCKWqxcc1PeqKFUqBranch:
claude/issue-8993-partial-masking-runtime-firstWorktree:
objectstack-issue-8993(+ sibling worktrees per repo if enforcement spans one — measure first)Domain:
domain:specFile 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:
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
feat(spec,plugin-security): partial field masking — maskingRule prese…
test(rest): pin export screen-vs-CSV parity for partial masking; add …
regenerate artifacts on the merged tree (post-#9113 merge) (#8993)
gates: ADR-0122 alias form + Iso850 pin; translate the new maskingRul…
os-zhuang commentedon 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
os-zhuang commentedon Aug 16, 2026
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.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.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.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..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
os-project-manager commentedon Aug 16, 2026
Triage 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
feat(spec,plugin-security): partial field masking — maskingRule prese…
fix(security): explain's field-mask layer reports partial masking (#9127
tests(access-security): pin the FLS read side and the persona x CRUD …
fix(approvals): stop pushing a free-text predicate onto a masked payl…
fix(security): explain's field-mask layer reports partial masking (ob…
tests(access-security): pin the FLS read side and the persona x CRUD …
fix(approvals): stop pushing a free-text predicate onto a masked payl…
test(spec): the second data/ file group's test titles state each cite…