Skip to content

The eight-bullet secret read-mask is declared twice with no gate binding them — objectql's SECRET_MASK and service-settings' SETTINGS_SECRET_MASK #7572

Description

@huangyiirene

Follow-up from the #7522 security fix (PR #7554), raised by the implementing dev and accepted by the PM as out of scope for that card.

Symptom

The mask a client sees in place of a redacted secret is now declared in two places, byte-identical by convention only:

Nothing binds them. An edit to either literal silently desynchronises the two masked-read surfaces a client sees, and the failure is invisible on both sides: each package's own tests keep passing, because each asserts against its own constant.

This is the declared = enforced shape one level up — the contract is "a masked read looks like this", and there is no single definition of "this".

Why it wasn't fixed in #7522

Deliberate, and the reasoning is worth keeping. The dev's own note:

The constant is redeclared rather than imported because this service is deliberately framework-agnostic: it defines its own minimal SettingsEngine instead of importing IDataEngine, and does not depend on @objectstack/objectql at all. Taking a runtime dependency on the whole data engine to reach one string would undo that.

Plus: a cross-package move whose consumer sweep is effectively the whole repo has no business riding on a security fix.

Options

A — hoist into @objectstack/spec, objectql re-exports for back-compat. Both packages already depend on spec, and ADR-0100 is already documented in spec/src/data/field.zod.ts. One definition, both sides import it. Recommended.

B — leave the duplication, add a cross-package pin test in a package that already depends on both (cli / plugin-email / verify). Binds the constants, but puts a contract pin in a package whose subject is something else.

C — add @objectstack/objectql as a dependency of service-settings. Rejected: it undoes the framework-agnostic design the settings service is built around.

A is the only option that leaves one definition. A mask is a client-facing contract, and "structurally hard to get wrong" here means the console cannot be shown two different masks.

Notes for whoever takes it

  • The duplication is inert until someone edits one of the two literals — this is a drift-prevention card, not a live defect. Priority accordingly.
  • packages/services/service-settings/src/settings-secret-redaction.ts already carries a comment stating the mirroring and naming this follow-up, so the next reader does not re-derive the analysis.
  • Both literals are spelled as the literal •••••••• rather than an escape, deliberately, so a grep for the mask finds both declarations. Keep that property in whatever lands.

Source

Follow-up from #7522 (PR #7554), which itself came from the QA run #7514.

Activity

  1. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings triage: resolved the co-applied pm:queue + finding dual state → finding (kept domain:spec). The card's own Notes rule the grade: "inert until someone edits one of the two literals — this is a drift-prevention card, not a live defect." Both masks are byte-identical today and each side is pinned by its own tests; nothing pages anyone until an edit desynchronises them.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Promoted finding → pm:queue by the spec-lane seat, executing the maintainer's lane-triage authorization (spec-lane PM session chat, 2026-08-11, verbatim: 「你的车道你可以执行 分类改标」; session session_01JY2Q5Xto1u8YHADgrZDTnk). Grading rationale: restore-invariant class — the eight-bullet secret read-mask is declared twice with no gate binding the copies; the repo already states the invariant (one mask), so the finding carries its own decision. Dispatch shape: bind with a gate/pin rather than hoping the copies stay in sync.


    Generated by Claude Code

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

    @os-zhuang
    Contributor

    Claim: PM loop round 2 (spec lane)
    Session: session_01JY2Q5Xto1u8YHADgrZDTnk
    Branch: claude/issue-7572-secret-mask-single-source
    Worktree: cloud dev session (own container)
    Domain: domain:spec
    File surface: packages/objectql/src/secret-fields.ts (SECRET_MASK, :42) + service-settings' SETTINGS_SECRET_MASK + the binding (shared constant hoist into packages/spec per the shared-contract rule, or a cross-package pin — dev measures which is the honest fix and states it in the PR). If the fix ends up touching NO spec file, report the deviation — a cross-seat transfer follows, not a silent out-of-lane landing. Stop on breach; explain in the report.
    Container & model: M (cross-package invariant binding with a design choice), mode:cloud, model: opus
    Serial constraints cleared: no in-flight writer on secret-fields.ts / service-settings mask (open PRs + queue checked 12:3xZ). Filter-family cards (#7596 in flight, #7711 queued behind it) share no file with this card.


    Generated by Claude Code

  5. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    OS-DEV-REPORT — #7572

    {
      "issue": 7572,
      "status": "done",
      "branch": "claude/issue-7572-secret-mask-single-source",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7759",
      "premise_still_valid": true,
      "summary": "Premise confirmed on origin/main before implementing: two byte-identical literals, SECRET_MASK at packages/objectql/src/secret-fields.ts:42 and SETTINGS_SECRET_MASK at packages/services/service-settings/src/settings-secret-redaction.ts:59, with nothing binding them. Implemented Option A: the mask is now declared once in packages/spec/src/data/secret-mask.ts and exported from the @objectstack/spec/data entry point; objectql re-exports it (public API byte-for-byte unchanged — same name, value and literal type, still exported from the package root and from core), and service-settings aliases it as SETTINGS_SECRET_MASK, keeping its published name. Placement was measured, not invented: ADR-0100's entire spec surface already lives in src/data/ (field.zod.ts's secret/password FieldTypes + ackPlaintextMasking, object.zod.ts's author-time diagnostic), and a dedicated leaf module beside them follows the existing default-value-tokens.ts shape. The #7522 framework-agnostic rationale was re-tested rather than overridden: it argued against depending on objectql (the whole data engine) and still holds — no objectql import was added anywhere in service-settings — but it never applied to spec, which is already a dependency AND already in that package's runtime graph (manifest.ts -> @objectstack/platform-objects/system -> @objectstack/spec/data, a value import in the platform-objects object modules). Pins judged individually per the card: spec-side byte + source-spelling pin restates the literal (a pin importing the constant to check the constant is green by construction); each consumer gained an identity pin plus one restated eight-bullet case; the far-side literal pins in plugin-audit and driver-memory, and the route-level assertions in settings-routes.test.ts, were deliberately left alone. Spec keeps the grep-findable literal spelling #7572 asked to preserve, now pinned by a source read.",
      "tests": "BUILD: pnpm --filter @objectstack/spec build (3m30s, full DTS); pnpm --filter '@objectstack/objectql^...' --filter '@objectstack/service-settings^...' build (5m32s) — dependency closure first, per the stale-artefact rule. TESTS: @objectstack/spec vitest run -> 'Test Files 378 passed (378) / Tests 9885 passed (9885)' (the -- swallowed my path filters, so this is the full spec suite). objectql vitest run src/secret-fields.test.ts -> 'Test Files 1 passed (1) / Tests 21 passed (21)'. service-settings full suite -> 'Test Files 19 passed (19) / Tests 399 passed (399)'. TYPECHECK: pnpm --filter @objectstack/objectql typecheck -> Done; pnpm --filter @objectstack/spec typecheck -> tsc + check:scripts-typecheck + check:test-typecheck all OK (1m07s). service-settings has NO typecheck script; running tsc -p tsconfig.json directly surfaces 5 pre-existing test-file errors (src/manifests/{ai,sms,storage}.manifest.test.ts, src/settings-service.test.ts, src/translations/settings-translation-coverage.test.ts — manifest i18n label typing and SettingsActionHandler), none in files this PR touches; reported, not fixed. GATES: check:generated -> initially '2 of 13 artifact(s) stale: api-surface/, export-origins/', regenerated exactly those two (each gained one line: 'SECRET_MASK (const)' and 'SECRET_MASK': 'src/data/secret-mask.ts#SECRET_MASK (const)'), then '✓ All 13 generated artifacts are up to date'. authorable-surface.base.json was NOT rewritten (no diff), tree never in MERGE state. check:nul-bytes OK (7117 files scanned); check:merge-driver OK; check:adr-anchors OK (22360 citations resolve); check:spec-parsed-alias OK; check:dual-source-exports '✅ no new dual-source exports: 4843 names, 170 re-exported (single declaration), 0 accepted dual-source'; check:exported-any OK. Note for anyone repeating this: those last two REFUSE to run against an OS_SKIP_DTS=1 build (they read the .d.ts and say so loudly, #7122) — my reverse-verification rebuild had used that flag, so a full spec rebuild was needed before they were meaningful. REVERSE VERIFICATION (direction predicted before running; both matched; restore via checkpoint commit + git checkout, never git stash): (1) break the ONE literal (8 bullets -> 7, spec rebuilt so consumers resolve it) -> spec pin RED on both cases (bytes AND spelling); objectql '1 failed | 20 passed' (restated case red, identity case green, every masked-read behaviour assertion green); service-settings '1 failed | 398 passed', same split. The identity cases staying green is correct, not a hole — they compare one declaration with itself; the ~30 green behaviour assertions are exactly why the restated pins exist. (2) re-introduce a drifted local literal in service-settings (the pre-fix world) -> all 4 new pins RED (identity first) while the pre-existing suite stayed at '395 passed', including every route-level mask assertion in settings-routes.test.ts — the card's invisibility claim, measured. Restore verified by spec's build-input hash returning to b1eb6b0ab6859342, a clean git status, and 399/399 green.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Changeset: one file, three packages, graded separately — @objectstack/spec minor (new public export SECRET_MASK on the data entry; additive, evidenced by the single added line in each of api-surface/data.json and export-origins/data.json), @objectstack/objectql patch (re-export; no public API change — export-origins confirms it resolves to a single declaration and the package's export list is untouched), @objectstack/service-settings patch (SETTINGS_SECRET_MASK keeps name, value and literal type).

    No skip-changeset label — this PR ships a changeset, so the label does not apply.

    Sibling scope: #7573 (the QA-checklist half) is untouched. docs/qa/platform-checklist/areas/platform-core.json still names SETTINGS_SECRET_MASK and settings-secret-redaction.ts, which remain accurate after this change.

    Gate status: in_progress — reported at draft-PR time per the dispatch contract; CI convergence, the ready-flip and landing are the PM's. Note: the platform auto-subscribed this session to PR #7759 with a stay-resident / drive-to-green posture. Per .claude/agents/os-dev.md (maintainer ruling 2026-08-11, #7512) the dispatch contract outranks it, so I am not idle-polling CI and have armed no self check-ins; a red gate comes back to me as a patch round.


    Generated by Claude Code

  6. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Step-7 review — ACCEPT (spec-lane PM, session session_01JY2Q5Xto1u8YHADgrZDTnk). PR: #7759 (draft, Fixes #7572).

    • Option A executed per the triage note's own restart condition: SECRET_MASK declared once in packages/spec/src/data/secret-mask.ts (placement measured against ADR-0100's existing spec surface, mirroring the default-value-tokens.ts reserved-vocabulary shape); objectql re-exports with an unchanged public API; service-settings aliases, keeping every import working. The [security] GET /api/settings/:namespace returns encrypted setting values as plaintext — no redaction at the REST read boundary #7522 framework-agnostic reasoning is preserved and shown to not apply (spec already in the package's runtime graph — evidenced, not asserted).
    • Pin-by-pin load-bearing analysis is the standout: restated pins vs identity pins judged individually, far-side literals (plugin-audit, driver-memory) deliberately left restated to keep their distance-coverage, and the reverse verification demonstrates the split exactly (identity green with itself, restated red on the byte change; the drifted-local-literal experiment reproduces the pre-fix blindness: 395 pre-existing tests green while drifted).
    • Changeset three-way grading with api-surface/export-origins line-level evidence (spec minor / objectql patch / service-settings patch).
    • Honest report: service-settings' 5 pre-existing tsc errors reported, not fixed. Cross-package landing (objectql + service-settings mechanical import swaps) was declared at claim time under the shared-contract rule — noted here as the cross-seat record.

    Landing: api-surface/export-origins are committed generated artifacts ⇒ joins the os-regen relay. Position: after #7756. Sync lap + flip when its slot clears.


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions