Skip to content

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

@os-zhuang

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 FieldMasker alongside 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

  1. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    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 (requiredPermissions mask-on-read; type:'secret'). No partial masking (phone last-4, ID middle-8). maskingRule was 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

  2. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor

    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 FieldMasker channel 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-5 tier 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:queue in the same stroke.


    Generated by Claude Code

  3. self-assigned this
    on Aug 16, 2026
  4. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor

    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 — expected plugin-security family), 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.ts placeholder) is landing right now: branch off current origin/main AND re-merge after #9113 lands before finalizing; both edits touch FieldSchema, declare region-level care. File-disjoint from in-flight #9040 (driver/mongo.zod.ts). The ruling's binding scope pins (single FieldMasker channel · closed presets phone/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

  5. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor
    {
      "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

  6. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor

    ACCEPT (content) — PR #9128 (claude/issue-8993-partial-masking-runtime-first, reviewed at head 54cda1e3b). Verified against GitHub, not the report's self-description. Ready-flip waits only on CI convergence.

    Landing: touches regenerated artifacts — relay discipline at queueing if main moves on those faces; flip ready + auto-merge at gate green.


    Generated by Claude Code

  7. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    Triage sweep: added pm:blocking (cache derived from the Blocked-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

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

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions