Skip to content

feat(spec,plugin-security): partial field masking — maskingRule presets + keepHead/keepTail, enforced on the FieldMasker channel (#8993) - #9128

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-8993-partial-masking-runtime-first
Aug 16, 2026
Merged

feat(spec,plugin-security): partial field masking — maskingRule presets + keepHead/keepTail, enforced on the FieldMasker channel (#8993)#9128
os-zhuang merged 5 commits into
mainfrom
claude/issue-8993-partial-masking-runtime-first

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8993

Implements the maintainer's 2026-08-16 Option A ruling (「主线二:批 A+A = 宣布进入监管行业市场(企业级合规包方向)」): partial field masking as a runtime-first capability, riding the existing FieldMasker channel, with the declarable key landing in the same PR as its enforcement.

What landed

Spec (packages/spec)

Enforcement (packages/plugins/plugin-security) — the single channel

  • maskFieldValue(value, rule): deterministic, length-preserving, idempotent transforms (same input → same masked value; list rendering and grouping stay stable). Non-string scalars/objects collapse to a fixed opaque mask so no length or truthiness bit leaks from shapes a rule was never written for. An invalid stored rule fails closed to a full mask, with a warning.
  • Who sees masked: a rule field is served masked to every non-system caller UNLESS the field also declares requiredPermissions and the caller holds all of them — the unmask gate is the existing ADR-0066 D3 evaluation, no parallel matrix. On-behalf-of reads require BOTH principals to hold them (ADR-0090 D10). A permission set that marks the field non-readable still wins entirely: the key stays deleted — a masking rule never widens an explicit deny.
  • maskResults REPLACES masked-for-caller values instead of deleting the key; runs on find/findOne and the write-echo images, so API caller and browser user are masked identically and the AI-context interceptor inherits it for free.
  • No oracle: masked-for-caller fields are folded into the predicate guard (filter/sort/group refuse 403, like FLS-hidden fields — equality probes would reconstruct the masked span) and into the aggregate input gate (min/max reveal values outright).
  • Masked-echo write refusal (detectMaskedEchoWrites): a payload value that is a fixed point of its own rule and carries the mask character is the placeholder a masked read served, round-tripped; writing it through would replace the stored value with 138****5678. Refused loudly with 400 VALIDATION_ERROR (MaskedValueWriteError; standard-catalog code, both status and statusCode spelled). Callers holding the unmask capabilities are exempt by construction, so a privileged import of literally-starred data stays possible. A short value that equals its own mask image but carries no * passes.
  • getReadableFields / getMetadataReadableFields keep masked-but-served columns in the projection (mirroring the step-4 exclusion exactly), so the security 服务 getReadableFields 查询面(export 列投影的长期正解) #3547 export header matches the masked values the same caller's rows carry.

Export path (packages/rest) — measured, no bypass
The PM's mechanism assumption held: GET /data/:object/export streams through findData with the caller's context, i.e. through the same engine middleware — there is no masking bypass on export. New export-integration.test.ts suite pins screen-vs-CSV parity (the auditors' probe): the CSV cell equals the list value byte-for-byte (写**), JSON export too, and the masked column's header survives.

Packaging split (per the AI-governance precedent)

cloud's security-enterprise AI-governance README documents the pattern: the open runtime carries the enforcement channel; Enterprise adds policy and inherits masking "by construction" through forced actor context. Applied here: the spec key, the preset tables AND the masking engine all live in the open runtime (spec + plugin-security) — a declared rule must be enforced in a community deployment too, or the key would be exactly the dead surface the 2026-06 prune removed. The enterprise compliance bundle (#8992 pairing) composes on top of this channel; nothing enterprise-only was created.

Semantics worth reviewing (the judgment calls)

  1. Rule without requiredPermissions masks everyone (non-system). The alternative — inert until a gate is declared — recreates a silently-dead declaration per field, the ADR-0104/ADR-0049 failure class. Declare = enforce from the first read; the unmask gate is opt-in.
  2. Masked read does NOT imply denied write (a caller may update a customer's phone while reading it masked — the CRM workflow); the echo guard is what makes that safe.
  3. Sorting by a masked field is refused (it rides the same predicate guard as filtering). If real UX pull for sort-by-masked-column shows up, relaxing is a deliberate follow-up decision, not a default.

Verification

Full local union re-run at the final commit — git rev-parse --short HEAD = 54cda1e3b (every reading below is from that head, post-#9113-merge):

  • pnpm --filter @objectstack/plugin-security test — 66 files / 1276 tests green (new field-masking-rule.test.ts: preset transforms, idempotence, applicability matrix, oracle-guard refusals with code+status, echo-write refusal, readable-projection).
  • pnpm --filter @objectstack/spec test — 406 files / 10781 tests green (declaration suite incl. closed-enum rejections; authoring-key-lint flip; Iso850 pin).
  • pnpm --filter @objectstack/rest test — 120 files / 2003 tests green (export parity suite).
  • Typechecks green: spec, plugin-security, rest, platform-objects.
  • check:generated all 13 artifacts current; check:liveness, check:i18n, check:i18n-coverage, check:spec-parsed-alias, ratchet family (type-check-coverage/debt, engine-double-contract, where-matcher, query-options-erasure), check:nul-bytes, changeset gates, check:dev-prereqs — green. Example apps objectstack validate (crm/showcase/todo) exit 0.
  • feat(spec): declare placeholder on FieldSchema — Option C ruling on objectui#4676 (#9019) #9113 (placeholder, same-file churn) landed mid-task and is re-merged via scripts/pm/os-regen-merge.sh; artifacts regenerated on the merged tree; both keys verified present in authorable-surface/data.json and the liveness ledger.

Changeset: minor on @objectstack/spec + @objectstack/plugin-security (accept-surface addition; not breaking, so no ADR-0087 marker owed).

Out of ruled scope, noted: the explain engine (security.explain) reports the field-mask layer as binary readable/masked and does not yet distinguish "partially masked" — filed as #9127.

Generated by Claude Code


Generated by Claude Code

…ts + keepHead/keepTail, enforced in FieldMasker (#8993)
Re-introduces the 2026-06-pruned maskingRule key WITH its runtime consumer in
the same PR (ADR-0049 declare = enforce; maintainer ruling 2026-08-16 Option A):
- spec: FieldMaskingRuleSchema — closed preset enum (phone/id_card/
bank_account/email/name) + keepHead/keepTail escape hatch; guidance
tombstone flipped (the #9019 placeholder precedent); liveness row live with
evidence; authoring forms; regenerated artifacts.
- plugin-security: maskFieldValue (deterministic, length-preserving,
idempotent); maskResults REPLACES masked-for-caller fields instead of
deleting (requiredPermissions is the unmask gate — same ADR-0066 D3
evaluation; explicit permission-set denies still delete); predicate +
aggregate guards refuse masked-field probes (the equality oracle);
detectMaskedEchoWrites refuses masked round-trip writes with 400
VALIDATION_ERROR; getReadableFields keeps masked columns in the projection
so export headers match served rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01225pUjnCKWqxcc1PeqKFUq
…e form strings (#8993)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01225pUjnCKWqxcc1PeqKFUq
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/platform-objects, @objectstack/plugin-security, @objectstack/spec.

109 hand-written doc(s) reference the affected code — list omitted above 15 rows. Re-derive: node scripts/docs-audit/affected-docs.mjs --json origin/main.

7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 31949519830 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 6 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 在其他 PR 的同类评论里搜同名测试;出现过 ⇒ flaky 实锤,开 issue 修/隔离那条测试。修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

Queue-failure triage (lane PM, own-PR handling — steward yield protocol observed). Build 31949519830 died in Dogfood Regression Gate (3/3) at the setup stepVerify pnpm version (AssertionError … falsy value) — before any test body ran. Per the triage checklist: not branch 1 (no test in this PR's packages failed — no test ran at all) and the signature matches the steward ledger's cross-repo infrastructure row (runner/toolchain setup death ⇒ re-queue as-is). First occurrence on this PR; not a code signal.

Action: re-queuing ONCE (auto-merge re-armed). A second identical setup death would suggest a runner-image/corepack drift worth its own card rather than another rebuild.


Generated by Claude Code

@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 16, 2026
@os-zhuang
os-zhuang added this pull request to the merge queueAug 16, 2026
Merged via the queue into main with commit 3851f87Aug 16, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8993-partial-masking-runtime-first branch August 16, 2026 14:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-zhuang@claude