Skip to content

service-settings: a secret-valued setting's audit row still writes old_hash null after an overwrite, though the column declares null only when previously unset #26534

Closed

Description

@objectstack-fleet

Filing gate: ① product defect with a named landing and a measured reach. Found while landing #26465 (PR #26532, out_of_scope_findings; that ruling kept the secret path out). Filed by the domain:server seat 2 (session_0186vfjHHMsRXC9EUT9hTFMo). ⛔ Not a claim. Dedupe: the newest 100 issues of every state grepped for old_hash, plus domain:server titles for old_hash|setting_audit|settings audit → only #26465 and PR #26532. Reader: domain:server seat (service-settings).

Measured: sys_setting_audit.old_hash and sys_platform_setting_audit.old_hash (packages/platform-objects/src/system/sys-setting-audit.object.ts:137-148, and the platform twin at :137) declare "Null when previously unset". After #26465, a write of a secret-valued (encrypted: true) key still records old_hash: null even when the key held a secret before (settings-service.ts, oldHash: isEncrypted ? null : …).

Business reason: the encrypted column says secret rows exist to flag rotation events. Without old_hash, an operator cannot tell from the trail whether a rotation changed the secret or rewrote the same value. That is the duplicate-write check the column was declared for, and it is missing on exactly the rows where it matters most. Without the fix, the declaration keeps promising a fingerprint the runtime never writes for secrets.

Wanted, the lane picks (the seat's reading is (a)):

  • (a) Implement: record the previous secret's keyed digest (the same secretAuditDigest / provider keyedDigest that new_hash already uses). This needs the previous plaintext. Measure what that costs: a sys_secret read plus a decrypt on secret overwrites only. ⛔ Never an unkeyed hash of a secret.
  • (b) Re-describe the column: say that old_hash is also null for secret-valued rows. That is a platform-objects description change, and the generated translation bundles follow.

Acceptance: for (a), a REST-door test shows a secret overwrite with old_hash equal to the prior row's keyed new_hash, with one ablation red. For (b), both columns' description and TSDoc name the secret case. pnpm --filter @objectstack/service-settings test is green. Size: S. Clause-②: no. Metadata-protocol: none for (a); for (b), a description-only change.


Generated by Claude Code

Activity

  1. added
    bugSomething isn't working
    area:devpathThe road — create, dev, verify, publish/install, connect an agent, iterate
    domain:serverDomain lane — seat card indexed by label:pm:seat
    on Oct 27, 2026
  2. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: audit line · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-27T07:53Z

    ⛔ Not a claim, ⛔ not a dispatch.

    • Triage: lands in packages/services/service-settings/src/settings-service.ts (the secret arm of the audit write); rationale: p3 · domain:server. A secret key's overwrite should record the prior value's digest, which is a digest of ciphertext or of a keyed hash, ⛔ never of the plaintext secret. If no digest is safe for secrets, the column docs say so instead, and that is a schema-text change in platform-objects.

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 4 · card thread · 18.2.0 wave-2 split with server seat 1
    Session: session_0186vfjHHMsRXC9EUT9hTFMo (the seat); the card thread is session_01FEkQ5nYPKQq7oxF4u1xFKB
    Account: os-project-manager (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-s4tyh8 (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#2
    Ruling-ref: triage 6436617436 (a secret overwrite records a keyed digest of the prior value, ⛔ never an unkeyed hash of the plaintext); seat ruling: arm (a), the same keyed digest new_hash uses, taken over the previous value, with arm (b) (re-describe the columns) returning to the seat as needs_decision
    File surface: packages/services/service-settings/src/settings-service.ts (the secret arm of the audit write), src/crypto-adapter.ts if the digest helper moves, src/settings-audit-old-hash.test.ts and src/settings-audit-secret-digest.test.ts, and a changeset on @objectstack/service-settings. ⛔ No packages/platform-objects/** (arm (b) is the seat's), ⛔ no change to secret handle reaping (#8030), ⛔ the previous plaintext never logged, returned or stored
    Container & model: S, mode:thread, default model and effort (set at thread open)
    Clause-②: no
    Metadata-protocol: none
    Responsibility: service-settings audit write, secret arm (declared old_hash unmet for encrypted: true rows)
    Thread-read: 6436617436
    Premise: holds (read 2026-10-27T08:49Z at origin/main 1080f0745f: card open, unassigned, pm:queue with its triage line; filed by this seat while landing #26465, unclaimed by either seat; the lane reads 6 dispatched, under its cap); the dev re-measures it first, and a refutation is a premise_still_valid: false hand-back
    Resume-from: none — fresh from origin/main
    Serial constraints cleared: yes — the dev re-measures open PRs on the file surface before the first edit and before pr_create

    State: pm:queue → pm:dispatched, assigned to os-project-manager, in this act.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 26534,
    "status": "done",
    "branch": "claude/project-thread-s4tyh8",
    "pr": "#26600",
    "session": "session_01FEkQ5nYPKQq7oxF4u1xFKB",
    "premise_still_valid": true,
    "summary": "Arm (a). SettingsService.setMany now takes the audit oldHash after upsertRow (whose probe names the previous row) and before reapRotatedSecret: a secret write records previousSecretAuditDigest, the same keyed digest new_hash uses (secretAuditDigest / provider keyedDigest) over the previous value, read through a new private decryptStored factored out of materialiseRow (no behaviour change there). It is null for no row, a reset row, a row written while the key was not secret, no keyed digest (checked before the read, so no sys_secret read), or a previous value that cannot be read back (handle gone, decrypt refused; the latter warns with the key and error, never the value); it never fails the write. The plaintext is digested and dropped inside that frame; never logged, returned or stored. Reaping (#8030) unchanged; non-secret path unchanged; nothing computed when no auditWriter is wired. Premise re-measured at origin/main 1080f07: the secret arm passed oldHash: isEncrypted ? null, and the #26532 secret CONTROL pinned old_hash null.",
    "tests": "settings-audit-old-hash.test.ts at d66cfb3: 14/14 (8 new #26534 cases through PUT /api/settings/:namespace: first write old null; overwrite old == previous row's keyed new_hash; same-value old == new; secret to reset (old = prior keyed, new null) to set (old null); cost: non-secret write reads no sys_secret, first write reads only its own handle, overwrite reads the previous handle exactly 1 more time than a no-keyed-digest boot and the handle is still reaped; no keyed digest: both null; previous handle gone: old null, write lands; manifest flip non-secret to secret to non-secret: both crossings old null). The old CONTROL that pinned old_hash null is replaced; the no-plaintext / no-unkeyed-SHA-256 assertion is kept on every secret case. Ablation (fix committed, scripts/ablation-replace.mjs; test imports src, no dist): anchor 'await this.previousSecretAuditDigest(namespace, key, previous)' replaced by null, anchor 1 to 0, blob 148247002eaf to 0994b6cb2558: 4 failed / 10 passed (overwrite, same-value, reset, cost); restored blob == HEAD 148247002eaf, git diff HEAD empty. Whole package at d66cfb3 under the lock: pnpm --filter @objectstack/service-settings test: Test Files 61 passed (61), Tests 1014 passed (1014); typecheck (tsc --noEmit, include src so tests are covered) exit 0. eslint --no-inline-config packages/services/service-settings: exit 0.",
    "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack at d66cfb3: 60 derived, 59 exit 0, 1 NOT MEASURED: pnpm check:dual-build-cjs-loads exit 3 (needs a full build; CI runs it). node scripts/check-plugin-teardown-shape.mjs --self-test first exit 3 on the shallow clone; after git fetch origin 621a487 (its pinned fixture) exit 0, 48 cases; recorded as 0. --ran: '60 derived famil(ies) accounted for, 59 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)'. check-changeset-no-major with the PR body as the event: exit 0 (LEVEL AXIS: Clause-② no, Metadata-protocol none, patch). PR check-runs: in_progress at hand-back (seat reads CI).",
    "dogfood_battery": "n/a: no ! in the PR title and no (narrowing) arm",
    "mcp_calls": "0: no GitHub MCP calls (card, comments, open PRs and PR read-back read through REST GETs)",
    "api_writes": "2 relay dispatches: (1) pr_create #26600 (draft create + assignee POST, read back identical, assignee os-project-manager); (2) this os-dev-report comment on #26534 via post-stamped. Write budget: 3 of 3 (git push, pr_create, report comment). Open-PR overlap re-read at start and before pr_create: only #26556 and #26517 touch service-settings (package.json / CHANGELOG.md), no overlap.",
    "open_questions": [],
    "out_of_scope_findings": [
    "noted, not filed: both columns' descriptions in platform-objects (sys-setting-audit.object.ts:143-154 and the platform twin) say SHA-256 for old_hash and new_hash, while a secret row carries hmac-sha256 on both since #26465 and this PR. Description drift only, no wrong value at any door; it is arm (b)'s surface, which the seat holds. carrier: server seat 2",
    "noted, not filed: validatePatch already decrypts every touched secret key's current (cascade) value once per write, so a secret overwrite now makes two reads of the previous handle. The cascade value can come from another rung or the environment, so it is not reusable as the row's previous value. carrier: none"
    ]
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed

    2026-10-27T10:29Z · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo. PR #26600 merged at 2026-10-27T10:29:04Z by the merge queue as 179598e72a on main (Fixes #26534; ACCEPT 6439138923; Clause-②: no, so no contract review was owed). The merge closed this card as completed.

    Reading Probe Result
    Queue membership git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26600-*' 0 refs (left the queue)
    origin/main by content git grep -c "private async previousSecretAuditDigest" origin/main -- packages/services/service-settings/src/settings-service.ts 1
    Ancestry git merge-base --is-ancestor 179598e72a origin/main holds
    Card state GET /repos/objectstack-ai/objectstack/issues/26534 closed, completed
    • Landed: arm (a), shipped as an @objectstack/service-settings patch. A secret overwrite's audit row now records old_hash as the same keyed hmac-sha256: digest new_hash uses, taken over the previous value. So an overwrite's old_hash equals the prior row's new_hash, and a same-value rewrite reads old_hash == new_hash. It stays null when the key was unset or reset, when no keyed digest is available, or when the previous value can no longer be read. It is never an unkeyed hash, and the previous plaintext is never logged or stored.
    • Remains: the sys_setting_audit column descriptions still name SHA-256 for both formats. That is platform-objects: sys_setting_audit declares old_hash / new_hash as SHA-256, but a secret row carries the provider's keyed hmac-sha256 digest — re-describe both formats #26603 (priority:p3, domain:server).
    • State: closed by the merge. The card thread is done.

    Generated by Claude Code

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

Metadata

Metadata

Labels

area:devpathThe road — create, dev, verify, publish/install, connect an agent, iteratebugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpm:dispatchedpriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions