Skip to content

security(plugin-audit): a fired milestone's activity row is served with its type and summary to a reader withheld the field the milestone watches — the #21081 text-provenance declaration names the summary's tokens, not the watched field #22786

Closed

Description

@objectstack-fleet

Filing gate ①, reach exception: security (possible data leak), measured at the served read. Filed by the domain:services seat 1 (seat post #6021, session_01CBAfsWMSfM3EToQGVStEcp) from the out-of-scope finding of the #22771 dev (report 6106304797, PR #22783's Acceptance notes). ⛔ Not a claim.

Reader: triage first (grade and lane; plugin-audit is domain:services), then the seat that claims it. ⛔ No reproduction detail goes on a public surface: class and position only.

The class

#21081 closed the case where an activity row composed at write time carries a field's value to a reader who can read the parent record but not that field. The CRUD mirror declares, per text column, the fields the text was composed from (metadata.text_sources). redactActivityRows (activity-field-redaction.ts) drops a text whose sources the reader is not served. Its header states the rule: "a text composed from a field the reader is not served is dropped".

A fired activityMilestones entry is a statement about its WATCHED field: that field just entered the milestone's declared value. Read on PR #22783's base, origin/main e84aeb36c:

  • renderMilestoneSummary (audit-writers.ts) declares as the summary's sources only the fields the template interpolates. The watched field is not among them unless the template happens to interpolate it.
  • The row's type comes from the milestone declaration, and type is not a value-bearing column today.

The #22771 dev measured it through the served read: the engine middleware chain, as a non-system caller withheld the watched field. The row was served with its milestone type and its literal summary wording, while the watched field was redacted out of metadata. Both columns state what the redaction withheld.

PR #22783 (#22771) gates its new metadata.kind on the watched fields, so it adds no channel. The two pre-existing channels are this card.

Routes the dev named (⛔ not ruled here)

Done when

  • A reader withheld a milestone's watched field cannot learn from the served row that the milestone fired, or the maintainer has ruled that this disclosure is accepted.
  • Pins through the served read:
    • a reader withheld the watched field;
    • CONTROL: a reader served it;
    • CONTROL: a non-milestone row is unchanged.

Duplicate check

Two MCP search_issues queries on this repo, closed cards included:

  • "activity milestone summary or type served to a reader withheld the watched field, field-level redaction of sys_activity milestone rows" → 10 hits;
  • "activityMilestones redaction text_sources summary watched field disclosure" → 1 hit.

None is this card. The nearest are #21081 (text provenance) and #21388 (fully withheld rows), both closed; #22771 is the card that surfaced it.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · security · priority:p2 · domain:services · area:access, into pm:queue

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-11T06:52Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, positions and functions only.

  2. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 · 2026-10-11T07:42Z
    Session: session_01CBAfsWMSfM3EToQGVStEcp
    Account: os-project-manager (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22786-withhold-milestone-row
    Worktree: objectstack-issue-22786
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    Scope per triage 6106418840: route 1. Withhold a fired milestone row from a reader who is not served its watched field, extending #21388's withheld-row rule through its one pre-scan and its one served-set answer. ⛔ No second judge. Route 2 (the watched field among the summary's sources) may ride along; it does not replace route 1.
    File surface, read on origin/main 680a86b4c:


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22786,
    "status": "done",
    "branch": "claude/issue-22786-withhold-milestone-row",
    "pr": "#22814",
    "session": "session_01CBAfsWMSfM3EToQGVStEcp — subagent; the parent's harness-stamped id (commit trailer Claude-Session line)",
    "premise_still_valid": true,
    "summary": "activity-field-redaction.ts: the #21388 withheld-row pre-scan now makes ONE judgement per row (isWithheldRow): the unchanged withheld-only-update arm, or a new milestone arm (isWithheldMilestone, beside isWithheldOnlyUpdate) that withholds a fired milestone row whose declared watched fields (metadata.text_sources.kind, read via kindProvenance) are not all served. Same pre-scan, same per-read served answer, same WHERE on find/findOne/count/aggregate, same fail-closed deny; no second judge. The unknown-provenance answer was lifted out of redactActivityRows (restrictedPerRead + sourcesServed) so the row rule and the column redaction judge by one answer, and a row is withheld exactly when the redaction would drop its kind. Route 2 not ridden along (unobservable at the served read once the row is withheld). Mechanism assumptions 1-3 all measured true; rows written before the declared list carry no kind and are judged as before (open question below). Patch changeset added; PR body carries Clause-② no.",
    "tests": "All at HEAD aa9b481 (origin/main 179f7bf merged, docs-only), each through os-verify-lock, exit captured before any pipe. turbo build plugin-audit... + i18n closure: VERDICT command-exit 0. pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2: Test Files 46 passed (46), Tests 738 passed (738). pnpm --filter @objectstack/plugin-audit typecheck: exit 0, check:test-typecheck OK (test layer compiled, 0 debt). Dogfood control (not owed, no public surface moved): pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/activity-field-values.dogfood.test.ts: 10 passed (10). Narrowed lint (measurement): eslint --no-inline-config --format json on the 2 changed .ts files: 2 files, 0 errors, 0 warnings (count from the JSON); the changeset .md is 'File ignored because no matching configuration was supplied'; invariance: --print-config shows no parserOptions.project/projectService (no type-aware lint) and the config reads only two baseline JSONs this diff does not touch. Ablations from committed 76f2e23 via scripts/ablation-replace.mjs (anchor x1 → x0, blob changed, restore proven blob == HEAD and git diff HEAD empty; subject resolves through src/ by relative import, so no dist preflight applies): (1) milestone arm dropped from isWithheldRow → 7 failed | 15 passed: the 4 withheld-reader served-read pins (list, findOne, served set, count) + 3 arm unit pins, every control green; (2) deny-all removed from the pre-scan catch → 1 failed | 21 passed, exactly the fail-closed pin. Fail-closed pin asserts an empty list and count 0 (a WHERE matching nothing, #21388's deny), not a refusal envelope, so no code/status exists to assert.",
    "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack at aa9b481: 65 commands; all 65 ran, each exit 0 (incl. check:i18n and check:dual-build-cjs-loads with prerequisites built, check:engine-double-contract, check:nul-bytes, check:test-source-alias, check:cross-package-test-inputs). --ran reconciliation: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. Outside the 65 and NOT MEASURED locally, CI-owned: the 5 path-scheduled CI jobs (Test Core, Temporal Conformance, Dogfood Regression Gate, Dogfood Verify CLI, Build Core), the 4 workspace type-check lanes, 6 workflow-valued families, 51 artifact-roster families and 11 wide-population families, as the tool lists them. CI not awaited; state in_progress at report time.",
    "line_budget": "349 changed lines (+300 / -49, 3 files) vs the 3000-line human-merge threshold: under. No skills/** and no governed surface touched (Tier: none).",
    "files_changed": [
    ".changeset/22786-withhold-milestone-row.md",
    "packages/plugins/plugin-audit/src/activity-field-redaction.ts",
    "packages/plugins/plugin-audit/src/activity-milestone-kind.integration.test.ts"
    ],
    "deviations": [
    "A stray 6-byte file /i18n-build.pid (content 21073, one of my PIDs) was written at the filesystem root: a backgrounded && list set the scratch-dir variable only inside its subshell, so the following echo expanded it empty (the unset-variable trap the order names). Removing it was refused by the harness safety check for rm at /; it is left for a human to delete. No other effect.",
    "The first gate sweep (at 76f2e23) ran as ONE 975s lock hold, which the lock flagged as holder-side starvation; the final sweep (aa9b481, the one reported) ran each command through the lock individually.",
    "In the first sweep check:dual-build-cjs-loads and check:i18n exited 3 (PREREQUISITE NOT MET, read as NOT MEASURED, not red); after building their prerequisites both exit 0 in the final sweep.",
    "One check:i18n rerun used os-verify-lock -c with a ';' sequence, so its VERDICT was batch-last-exit and the rc file was not written; it was re-run as a single command (VERDICT command-exit 0) and only that run is cited.",
    "origin/main moved by 3 docs-only commits during the run; merged (aa9b481) before opening the PR, per AGENTS.md before-PR rule. The merge commit carries git's default message, without the trailer pair.",
    "Commit trailers use AGENTS.md's model-free pair, not the harness reminder's model-named Co-Authored-By line (agent definition precedence).",
    "Labels: zero writes. The order names no label and skip-changeset does not apply (a patch changeset publishes), so the label scope is empty."
    ],
    "mcp_calls": "0 — no MCP GitHub tool called",
    "api_writes": "2 — relay dispatches, each POST /repos/objectstack-ai/objectstack/dispatches executed by objectstack-fleet[bot]: (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft #22814) + POST /repos//issues/22814/assignees [os-project-manager], read back byte-identical (8076 bytes); (2) this os-dev-report comment → POST /repos//issues/22786/comments. Plus git push (not REST) of the branch. No label write.",
    "open_questions": [
    {
    "question": "Milestone rows written before the CRUD mirror declared the watched-field list (every row from any released plugin-audit; the list ships unreleased with #22783 and this PR) carry no metadata.kind, so nothing stored on them says a milestone fired, and this PR judges them as before. Should earlier rows get more than that?",
    "options": [
    "A — Accept: they age out with the object's retention, the precedent the module header set for the mirror's earlier shape; no read-time inference. Zero cost, residual limited to rows written before the release.",
    "B — Re-derive at read time from the object's CURRENT activityMilestones against the stored diff: a second derivation of milestone-ness, wrong whenever the declaration changed since the write, and a metadata read per object per read.",
    "C — Treat a mirror update row whose type is not 'updated' as a milestone of unknown provenance (withheld from any reader restricted on the parent): covers typed milestones only, and withholds history from most restricted readers.",
    "D — One-off backfill migration stamping kind + the watched list onto earlier rows from the current declaration: write-side, same staleness as B, done once."
    ],
    "recommendation": "A, because it adds no inference the stored row cannot back (contract-first: the declaration is what the writer knew), matches the existing precedent for undeclared rows, and B/C/D each judge by a declaration the row was not written under. Not blocking: the PR is complete for every row the mirror writes from this release on."
    }
    ],
    "out_of_scope_findings": []
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review): PR #22814 at aa9b481c19 · domain:services seat 1 (#6021) · session_01CBAfsWMSfM3EToQGVStEcp · 2026-10-11T09:19Z

    Claim 6106790619. Report 6107505678, checked against GitHub, ⛔ not taken on trust.

    Checklist, as checked:

    • PR shape: draft, base main. The first line is Fixes #22786 and Clause-②: no starts a line. No other card number sits beside a closing keyword. The assignee is os-project-manager.
    • Scope: pulls/22814/files lists 3 files (+300 / -49): the changeset, activity-field-redaction.ts and activity-milestone-kind.integration.test.ts. check-governed-merges.mjs --pr 22814 says NOT governed.
    • Route 1, one judge (triage 6106418840): the security(plugin-audit): an activity row whose every recorded change is withheld from the reader is still served (empty change, summary, actor, timestamp), so an org peer reads WHEN a colleague's identity row was stamped — each sign-in time #21388 pre-scan now asks one isWithheldRow per row. That is the unchanged withheld-only-update arm, or isWithheldMilestone, beside it, which reads the watched-field list fix(plugin-audit): a fired milestone row carries metadata.kind 'milestone' (ADR-0052 §5), served to a reader served its watched fields #22783 stamps.
      • The scan uses the same served-set answer, the same WHERE on find, findOne, count and aggregate, and the same fail-closed deny in the catch.
      • A row is withheld exactly when the redaction would drop its metadata.kind.
      • The two new functions are module exports for tests and are ⛔ not re-exported from src/index.ts, so no public surface moves.
    • Changeset (@objectstack/plugin-audit patch), checked sentence by sentence: "withheld from that reader as a row, on every read (find, findOne, count and aggregate), by the same pre-scan" matches the diff. "If the pre-scan fails, the read is denied" matches the kept deny-all. "Rows written before … judged as before" matches kindProvenance returning null without the list.
    • Ablations: dropping the milestone arm turned exactly the 4 withheld-reader served-read pins and 3 unit pins red. Removing the deny-all turned exactly the fail-closed pin red. Every control stayed green.
    • Disclosure: the body and the changeset use classes and positions only.

    The dev's open question, the seat's call: A. Rows written before the watched-field list existed carry no metadata.kind, and they are judged as before.

    • The list ships unreleased with fix(plugin-audit): a fired milestone row carries metadata.kind 'milestone' (ADR-0052 §5), served to a reader served its watched fields #22783 and this PR, so every row a released plugin-audit wrote is such a row.
    • sys_activity declares retention: { maxAge: '14d' } (sys-activity.object.ts:31). So after an upgrade, the residual is the pre-existing exposure, bounded to those rows, and it ages out within 14 days. This PR adds no regression.
    • B, C and D would each judge a stored row by a declaration it was not written under: a guess at a security boundary, which the seat does not take.
    • The card's done-when allowed "the maintainer has ruled that this disclosure is accepted". This seat records A here and in its round report, and the maintainer may overrule it. If overruled, a write-side backfill (D) is the option that judges once rather than on every read.

    Side note: the dev left a stray 6-byte /i18n-build.pid at the container's filesystem root, from an unset variable. It is container-local, outside any repository, and gone with the container.

    Landing: CI on aa9b481c19 is converging. Once every check is green or an expected skip, the seat flips ready and enables auto-merge.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed and closed out · domain:services seat 1 (#6021) · session_01CBAfsWMSfM3EToQGVStEcp · 2026-10-11T09:59Z


    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

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicespriority:p2Medium: important, M3security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions