Repository navigation
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
Description
Activity
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actionsBlocked-by: #8993
Triage: lands in
packages/plugins/plugin-security/src/explain-engine.ts⇒domain:identity. Type Bug — the module contract at the top ofsecurity-service.tsdeclares explain "matches enforcement by construction", and the partial-mask dimension now falls outside that construction (declared ≠ enforced on a shipped surface).Queued but blocked: #8993 is in flight (
pm:dispatched) and this card's target semantics are exactly what that PR lands. Dispatching in parallel would review an enforcement channel that is still moving. At unlock: re-verifycomputePartialMaskRules's exported shape on the merged ref before dispatch — the ruling pinned the enforcement channel, so mirror it, do not re-derive it.Rationale: three-state reporting (hidden / partially masked / readable) restores the explain-matches-enforcement invariant; no decision needed — the enforcement semantics are already ruled on #8993, this card only makes the explanation stop lying about them.
Triage: this comment comes from the triage seat Routine; not a claim.
Generated by Claude Code
os-project-manager commented
on Aug 16, 2026 CollaboratorMore actionsUnlock scan (triage seat): upstream #8993 closed
completed2026-08-16T14:33Z via merged PR #9128 (partial masking landed on the FieldMasker channel) →pm:blockedremoved, card returns to thedomain:identityqueue.Card face re-verified on the merged ref:
explain-engine.tsstill has zero awareness ofmaskingRule/computePartialMaskRules(grep clean), whilecomputePartialMaskRulesis live insecurity-plugin.ts— both misreports described in the body are now the shipped behaviour. The card stands as written and is dispatchable; the enforcement semantics to mirror are in PR #9128.
Generated by Claude Code
os-project-manager commented
on Aug 17, 2026 CollaboratorMore actionsClaim — PM dispatch seat, session
session_01Y26DJEHSBhhAQ6wwfsHNza, branchclaude/issue-9127-explain-partial-mask-reporting.✅ Unlock verified before dispatch, not assumed. Triage held this behind #8993 because that PR was still moving the enforcement semantics this card must mirror. #8993 closed
completedvia merged PR #9128, and the 23:21Z unlock scan re-verified the card face on the merged ref:explain-engine.tsstill has zero awareness ofmaskingRule/computePartialMaskRules(grep clean) whilecomputePartialMaskRulesis live insecurity-plugin.ts. Both misreports in the body are the shipped behaviour today.⛔ No decision needed and none to make. The enforcement semantics were ruled on #8993 and landed in PR #9128. This card only makes the explanation stop lying about them — three-state reporting (hidden / partially masked / readable), restoring the module contract's own claim that explain "matches enforcement by construction".
⚠️ Binding instruction carried from triage: mirror the enforcement channel, do not re-derive it. PR #9128 pinned it; a second independent derivation of the same semantics is how explain drifts from enforcement again one release later — which is precisely the defect being fixed.
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026 os-project-manager commented
on Aug 17, 2026 CollaboratorMore actions{ "issue": 9127, "status": "done", "branch": "claude/issue-9127-explain-partial-mask-reporting", "pr": "https://github.com/objectstack-ai/objectstack/pull/9328", "premise_still_valid": true, "summary": "Premise re-confirmed on current main (e4e5c6e3c, ~13h after the unlock scan): explain-engine.ts was still grep-clean of both maskingRule and computePartialMaskRules, and computePartialMaskRules was live only in security-plugin.ts. The fls layer now reports three states — hidden (key deleted) / partially masked (key served, value replaced, applicable rule named) / readable — and answers `narrows` whenever either dimension bites, where a gate-less rule previously produced `not_applicable`. Mirrored, not re-derived, per the binding triage instruction: the full composition (computePartialMaskRules PLUS the explicit-deny exclusion, which must read the RAW evaluator map because foldFieldRequiredPermissions denies exactly the capability-gated fields a rule is meant to soften) was already duplicated at two enforcement sites and is lifted into one SecurityPlugin.computeReadPartialMaskRules that result masking, getReadableFields and explain all call, injected via a new REQUIRED ExplainEngineDeps.getPartialMaskRules. The hidden/partial split in the report is FieldMasker.maskResults' own rule, so the report cannot disagree with the masking it describes. Required rather than optional deliberately: the decision has three outcomes, the binary getFieldMask expresses two, so an omission would silently re-open both misreports — a compile error is the right failure. Changeset added as minor (the required dep is type-breaking for a direct embedder).", "tests": "All at HEAD 032321660 (final commit; tree clean, ablation reverted, verified before every run below). pnpm --filter @objectstack/plugin-security test -- --maxWorkers=2 → 'Test Files 66 passed (66) / Tests 1292 passed (1292)'. pnpm --filter @objectstack/plugin-security typecheck → 'tsc --noEmit' echoed, EXIT=0 (script name confirmed in output, not a zero-match no-op). Build closure first: pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-security^...' build → EXIT=0. REVERSE VERIFICATION, direction predicted BEFORE running: predicted RED on the two card misreports and GREEN on the states the old code already got right; ablation kept the dep plumbing and reverted only the reporting rule, so nothing could fail for a missing-symbol reason. Observed exactly that — 8 failed | 1284 passed (1292), with both misreports reproducing verbatim: \"expected '1 field(s) masked from responses: [ph…' not to contain 'masked from responses'\" (gated field read as fully hidden) and \"expected 'not_applicable' to be 'narrows'\" / 'No field-level masking applies.' (gate-less rule field read as fully readable). Green in both directions as predicted: the HIDDEN control, 'stays not_applicable when neither dimension applies', BOTH explicit-permission-set-deny cases (unit + integration — the state where a rule must NOT demote a deletion), the delegator pass-through, and the pre-existing 'lists masked fields in the fls layer' test (hidden-path detail kept byte-compatible). Ablation restored via git checkout of the branch ref; grep for ABLATION markers returns 0. GATES — derived with node scripts/pm/dispatch-gates.mjs against the actual changed paths, then re-derived against the final diff; every one run, all PASS: check:nul-bytes, check:authz-resolver, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:objectui-changeset, check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, scripts/check-cross-package-test-inputs.mjs, scripts/check-empty-changeset.mjs, scripts/docs-audit/check-affected-docs.mjs. The two build-dependent ratchets were NOT skipped: full workspace build first (turbo run build --filter=./packages/* --filter=./packages/*/* --concurrency=2 → 70 successful, 70 total, EXIT=0), then check:i18n → 'OK (9 package(s) — all bundles in sync)' and check:type-check-debt (--re-measure) → 'OK — 33 ledger entr(ies) re-measured in 329.2s, 1926 raw tsc error(s) total, none above its recorded number'. The latter was run rather than reasoned away because @objectstack/plugin-security carries a TEST_DEBT entry (errors: 11) and this change edits three of its test files. No ablation of a dogfood/dist package was involved, so no dist rebuild applies. Lock discipline: every build/test run wrapped in flock -E 99 on /tmp/os-heavy-verify.lock; one queue timeout (exit 99, holder PID 21637 — a parallel agent's live build) was spent on lock-free work (branch push, PR body) and then re-acquired.", "open_questions": [], "out_of_scope_findings": [ "filed as #9329 (finding label, unassigned, no pm:queue): the fls layer's three field-mask states live only in the `detail` free-text string — ExplainLayerSchema has no structured per-field slot, so a consumer wanting which-field-in-which-state must parse prose. Filed as observation-class, not a defect: measured on main, no in-repo consumer reads the fls layer's detail or matches layer === 'fls' outside plugin-security, and no publish gate consuming explain reports exists yet — a structured field today would be a declared-but-unread surface on a security report." ] }
Generated by Claude Code
- added a commit that references this issue
on Aug 23, 2026
Summary
#8993 landed partial masking (
field.maskingRule): the read path now REPLACES a masked-for-caller field's value with its partial mask instead of deleting the key, gated by the field'srequiredPermissionsas the unmask evaluation.The explain engine's field-mask layer predates this and reports only the binary mask set (
packages/plugins/plugin-security/src/explain-engine.ts~1010-1023:hidden = readable === falsefromgetFieldMask, rendered as "N field(s) masked from responses"). Two misreports follow for a caller subject to a partial rule:maskingRule+requiredPermissionsthe caller does not hold is listed as masked-from-responses, but the caller actually receives the partially masked value (138****5678), not an absent key;maskingRuleand norequiredPermissionsis reported under "No field-level masking applies" / not listed at all, while every non-system caller sees it partially masked.The contract at the top of
security-service.tssays explain "runs the same resolution/evaluator/compiler the enforcement path uses, so the explanation matches enforcement by construction" — the partial-mask dimension now falls outside that construction.Suggested shape
Extend the layer to consult
computePartialMaskRules(security-plugin.ts) and report three states per affected field: hidden (deleted), partially masked (with the rule), readable — keeping the D10 agent/delegator intersection semantics it already applies to the binary mask.Provenance
Found while implementing #8993 (out of ruled scope there — the ruling pinned the enforcement channel, not the explain surface). See PR for #8993 for the enforcement semantics the explanation should mirror.
Generated by Claude Code