Skip to content

Enrollment privacy review: deadline hold + hypaware-privacy skill + hyp purge + query-seam filter (LLP 0100-0108)#316

Merged
philcunliffe merged 17 commits into
masterfrom
integration/enrollment-privacy-review
Jul 14, 2026
Merged

Enrollment privacy review: deadline hold + hypaware-privacy skill + hyp purge + query-seam filter (LLP 0100-0108)#316
philcunliffe merged 17 commits into
masterfrom
integration/enrollment-privacy-review

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

Implements the enrollment privacy review flow specified in LLP 0100 and
settled by decisions LLP 0101–0107 (merged in #308). Plan: LLP 0108.

The flow inverts the retired in-login picker (LLP 0102/0094): hyp remote login
stays fast, the first sync to the org server is held to a printed absolute
deadline
(LLP 0101), and the user reviews the settled cache afterwards in their
own agent session via the new hypaware-privacy skill, applying choices
through hyp verbs before anything ships.

Tasks (LLP 0108)

Task LLP Summary
T1 0103 Machine-local store v2: per-entry class ignore|local-only|full, v1 migrate-on-read, resolver merge, capture-seam plumbing so machine-local ignore actually stops recording
T2 0103 Class-aware marking verbs: hyp ignore --private / --local-only / --sync, symmetric unignore, --check names the governing source
T3 0104 hyp purge destructive verb: by path subtree / --session / --ignored sweep / --all; cache-only, never touches sinks or remote
T4 0105 Query-seam local-only visibility (security-critical): one shared filter at executeQuerySql for query/graph/MCP; caller-class lattice; closes the transcript-leak path
T5 0101/0102 First-sync deadline hold (generalizes pick_pending; next 11:59pm, +1d under 4h; written before enrollCentralSink); picker path deleted
T6 0100 R1/R9 Login prints the deadline + backfill statement + skill hint (TTY and non-TTY); hyp status shows the live hold
T7 0107 Skills ride attach: manual attach and the reconciler share one skill materialization; marker-tracked reversal on hyp leave
T8 0106 Session-start classification hook: unclassified cwd asked once (sync/local-only/ignore) via the T2 verbs
T9 0100 §skill The hypaware-privacy skill for Claude and Codex: the six-step review job, registered in both plugin manifests

Each task landed as a verified --no-ff merge; tests accompany every task
(purge-command, query-local-only-visibility, first-sync-hold,
usage-policy-classification, ignore-private-sync-command, claude/codex
classify-hook, status-first-sync-hold, and others).

Generated by neutral; held as a draft for human review and merge — neutral
never merges. Coverage of spec LLP 0100 closes via the code @refs carried in
these commits (per the spec's ## @ref annotations code will carry section).

Change-Set: enrollment-privacy-review

philcunliffe and others added 16 commits July 13, 2026 15:15
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the picker (LLP 0101/0102)

Generalize the pick-pending export hold into a first-sync review window: on an
attended enrolling login the sink driver exports nothing until a printed,
absolute deadline (the next local 11:59pm, rolled to the following day when
under a 4-hour floor). The deadline lives INSIDE the marker so an hours-long
hold survives incidental touches; corrupt or past markers read as absent
(fail-open), and a past deadline is opportunistically unlinked.

- src/core/usage-policy/first_sync_hold.js: new module (replaces
  pick_pending.js). computeFirstSyncDeadline, writeFirstSyncHoldMarker (writes
  usage-policy/first-sync-hold.json), readFirstSyncDeadline.
- src/core/sinks/driver.js + types.d.ts: tick() holds whole ticks while
  now < deadline; held reason renamed pick_pending -> first_sync_hold.
- src/core/cli/remote_commands.js: the fresh-enroll fork writes the hold BEFORE
  enrollCentralSink and never clears it (runs to its deadline, no finally
  clear); hyp join and re-logins hold nothing. Picker retired per LLP 0102:
  ENROLLMENT_PICKER_ENABLED, refineLocalOnly, the captured-directory wait
  (waitForCapturedDirectories) and freshenCaptureEnumeration removed; every
  fork prints the durable-command hint.
- src/core/commands/local_only.js: runLocalOnlyPicker + its wiring deleted;
  listCapturedDirectories, CAPTURE_DATASET and DURABLE_HINT survive as the
  skill's survey substrate. Picker result types dropped from types.d.ts.
- llp/0101: add the {#hold} anchor the plan's driver-check @ref targets.
- Tests: first-sync-hold.test.js (deadline incl. 4h floor + DST-agnostic
  invariant, marker fail-open, driver hold/release); remote-login and
  local-only tests reworked to the retirement + hold-ordering behavior.

Task-Id: T5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(LLP 0103 cli)

Extends the machine-local usage-policy list from a flat local-only dirs
array (v1) to a class-per-entry store (v2: {dir, class}), migrating v1
files on read, so `hyp ignore`/`hyp unignore` can mark a subtree with any
of the three usage classes without touching the committable .hypignore
dotfile:

- `hyp ignore --private` records `ignore` in the machine-local store
  (never recorded, never a repo breadcrumb, unlike the dotfile).
- `hyp ignore --local-only` keeps the existing local-only behavior.
- `hyp ignore --sync` records an explicit `full` entry: the implicit
  default made durable, so a later classification pass can tell "asked;
  syncs" apart from "never asked".
- `hyp unignore` grows the same three flags, symmetric removal.
- `hyp ignore --check` now names the governing source (`dotfile` /
  `machine-local` / `none`) alongside the class, and points at `hyp purge`
  when residual cached rows remain.

Marking/unmarking stays non-destructive: it only ever edits the
machine-local list file, never touches cached rows (that split is
`hyp purge`'s job).

Includes the minimal LLP 0103 store/resolver foundation this task
depends on (v2 read/write, matcher class-per-entry resolution, most-
specific-wins among nested list entries) - the capture-seam adapter
wiring (the five createUsagePolicyResolver() call sites in the Claude/
Codex plugins) is out of scope for this task and left for its own task.

Fixes a resolver tie-break bug found by this work: an explicit `full`
list entry used to lose its governedBy to the implicit default on a
same-rank tie, which would have made --sync's idempotency check and
--check's source-naming silently misreport "not governed by anything".

@ref LLP 0103 [implements]: class-aware marking verbs, dotfile meaning untouched, non-destructive marking

Task-Id: T2
Implements the retroactive-deletion verb settled by LLP 0104: `hyp purge`
removes already-cached rows from this machine's local query cache, cache-only
(never contacts a sink or the remote). Four targets, exactly one required
(bare `hyp purge` errors):

  - <path>        rows whose cwd equals or descends from the path (subtree)
  - --session <id> one session's rows
  - --ignored     every row whose cwd currently resolves to ignore
  - --all         every recorded row

Mechanics (resolved here per the decision-first convention, LLP 0104 deferred
them to "the design doc that follows"): deletion is via Iceberg position-deletes
(deleteMatchingRows, the same primitive the retention enforcer uses), NOT a
rewrite of survivors. This preserves surviving rows' part_id identity and every
sink's _hyp_ingest_seq watermark, so a purge-then-re-record is idempotent
server-side (chunk dedupe absorbs the identical part_ids) and purged rows never
resurrect via a stale watermark. Marking (hyp ignore) stays non-destructive.

Confirmation prompt on a TTY, --yes for scripts. Resurrection warning driven off
the cwds actually deleted: any that still resolves `full` will be re-imported by
the next backfill (--ignored dirs are durable, so they never warn). Structured
purge.* telemetry with row/partition counts and a hashed target token.

- src/core/cache/iceberg/store.js: deleteMatchingRows position-delete primitive
- src/core/cache/purge.js: purgeCache orchestration over discovered partitions
- src/core/commands/purge.js + core_commands.js registration: the CLI verb
- src/core/cache/types.d.ts: PurgeTarget / PurgeSummary
- test/core/purge-command.test.js: 17 tests (each target shape, segment-aware
  subtree, part_id + watermark integrity, cursor recount, idempotency, and the
  full CLI: validation, confirm gate, resurrection warning, --json)
- hypaware-core/smoke/flows/purge_removes_cached_rows.js: hermetic smoke proving
  purged rows vanish from `hyp query` results via the real CLI dispatch

Task-Id: T3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the transcript leak: a query running inside a captured session in a
synced directory can no longer surface local-only rows into that session's
transcript through hyp's own read surfaces.

One shared filter at executeQuerySql (never per command, the LLP 0049 R4
rule) now consults the same two-source usage-policy resolver the export
seam uses, twice: once for the caller's cwd and once per row. A row is
included iff the caller's class >= the row's class on the restrictiveness
lattice (ignore > local-only > full); an unknown caller (no derivable cwd)
fails closed. Every surface funnels through it: hyp query sql, hyp graph
neighbors, and the MCP tools (the stdio host's spawn directory is the
caller's context).

The wrapper refuses every scan fast path that could bypass the filter:
cwd is forced into projected scans and stripped back off, scanColumn and
numRows are withheld (a column stream cannot carry cwd; COUNT(*) must not
count withheld rows), pushed-down LIMIT/OFFSET is not forwarded, and WHERE
is kept from suppression-active datasets so a predicate over suppressed
content cannot reveal a row's presence.

Graph provenance (LLP 0105 #graph-provenance) resolves on the suppression
side: node/edge rows aggregate across sessions and carry no per-row cwd,
so the datasets declare their content-bearing columns
(localOnlyContentColumns) and restricted callers get structure with
natural_key/label/props/source_keys nulled, counted and reported.
Propagating per-row provenance through the projection merge remains the
higher-fidelity follow-up.

Withholding is never silent: results carry a localOnly report, the verbs
print the withheld/suppressed counts (only ever counts) on stderr, and
usage_policy.query_withhold telemetry mirrors the export seam's
discipline. --include-local-only on both query verbs is the
informed-consent override; its help text names the transcript-capture
consequence.

Kernel-internal cache-to-cache reads (graph projection and its dedup,
enrich pipeline, capture-directory enumeration, ignore --check residual
counts) bypass the filter explicitly, each with its justification, since
their output never enters a transcript and enumeration is the privacy
review's survey substrate.

Tests: caller-class lattice truth table, unknown-caller exclusion,
projection/scanColumn/numRows/LIMIT/WHERE bypass closure, suppression
semantics, override plumbing, resolver fail-loud. New hermetic smoke
local_only_query_withhold drives the real CLI from synced, local-only,
and overridden caller contexts; local_only_export_withhold updated to the
refined "queryable from private contexts" semantics.

The 11 smoke flows failing on this branch (blob_sink_parquet_local_fs,
cli_bundled_plugins_activated, client_attach_on_join, config_load_validate,
context_graph_projects_rows, gateway_claude_capture, local_parquet_export,
remote_oidc_login, s3_sink_export_fixture, walkthrough_picker_to_first_query,
walkthrough_to_first_query) fail identically on the integration base and
are unrelated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task-Id: T4
…4' into HEAD

# Conflicts:
#	src/core/usage-policy/index.js
#	src/core/usage-policy/matcher.js
…LP 0100 R1/R9)

The attended enrolling login fork now prints the first-sync deadline it
already writes: an absolute local time, the backfilled-history statement,
and the hypaware-privacy skill invocation hint, on stderr the same way
whether stdin is a TTY or not. hyp status surfaces the same pending
deadline (text and --json) while the hold is live, sharing one date
formatter with the login message so the two consent surfaces never drift
on wording. A corrupt or expired hold marker still reads as absent per
LLP 0101's fail-open polarity.

Task-Id: T6
…§skill)

The agent-assisted privacy review the deferred first sync directs the user
to run. One shared six-step body per client (LLP 0107 sharing convention):
protect the review session via the gateway session opt-out and verify it
(R3), check backfill settlement before surveying (LLP 0094 failure mode),
survey captured cwds with the LLP 0069 enumerate query and sample content,
explain ignore/local-only/sync in plain language before the first marking
(R5), propose redacted excerpts with per-item confirmation (R4), and apply
only via `hyp ignore --private/--local-only/--sync` plus an offered
`hyp purge` for every ignored directory and flagged session (R6, R7). States
the LLP 0105 scope honesty (hyp surfaces only).

The Codex variant embeds its own session-id discovery from the newest
`~/.codex/sessions/**` rollout `session_meta.id`, cross-checked against the
working directory, since Codex exposes no CLAUDE_CODE_SESSION_ID and has no
hypaware-ignore skill today.

Registered for both clients in each plugin manifest and index.js skill loop
(R8).

Task-Id: T9
…LLP 0106)

On an enrolled machine, an interactive session opened in a directory with no
explicit governing usage class is asked once to classify it (sync /
local-only / ignore), writing the answer through the same T2 marking verbs
(`hyp ignore --sync/--local-only/--private`). Choosing sync writes the
explicit `full` entry that suppresses the next prompt; every other case
(unenrolled, already classified, non-interactive/headless) passes through
silently and the hook never hangs or fails a run.

- src/core/usage-policy/classification.js: shared, client-agnostic core - the
  decision (decideClassification), the pinned consent copy
  (buildClassificationPrompt), the class->verb mapping (verbArgvForClass), and
  the disk-reading evaluator (evaluateCwdClassification) that treats a
  governedBy != null resolve as "asked and answered".
- claude: `hyp claude-hook classify-cwd` emits SessionStart additionalContext
  (Claude's context-injection "block with a prompt"); installed at attach on
  SessionStart + CwdChanged and recorded in the marker so `hyp detach` /
  `hyp leave` strip it via the LLP 0044 perimeter.
- codex: `hyp codex-hook classify-cwd` degrades to a firm plain-text
  first-prompt nag (Codex has no context-injection hook); same decision, copy,
  and verbs.

Tests pin the copy, the decision truth table, enrolled/unenrolled/classified/
non-interactive branches, the never-throw contract, and that the answer lands
via the real `hyp ignore` verbs. npm test (2226 pass), typecheck, and
build:types all green.

Task-Id: T8

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-1 review BLOCKER: the five capture-seam usage-policy resolvers
(claude projector/settle/backfill, codex exchange-projector/backfill)
were constructed with no `localOnlyListPath`, so they fell back to a
`.hypignore`-dotfile-only resolver blind to the machine-local list. A
directory marked via `hyp ignore --private` (the primary `ignore` path,
since marking verbs forbid repo dotfiles) therefore kept recording, and
`hyp backfill` re-imported it after a purge - defeating the class's
"do not record this folder's sessions at all" promise and the SKILL.md
durability claim.

Thread `localOnlyListPath(ctx.paths.stateDir)` from each plugin's
activate() into all five factories, matching the already-correct export
seam (activation.js), query seam (visibility.js), and purge (purge.js).
The drop decision stays class-aware: only `ignore` yields
USAGE_POLICY_DROP; `local-only` still records (queryable locally,
withheld later at the export seam per LLP 0070/0105); `full`/sync record
normally.

Tests:
- capture-seam-machine-local-list.test.js: constructs the real claude +
  codex projectors with a written machine-local list (no injected
  resolver) and asserts an `ignore` dir DROPS at capture while a
  `local-only` dir still records.
- context-graph-content-column-allowlist.test.js (Finding 3): pins that
  every node/edge schema column is classified content (CONTENT_COLUMNS /
  localOnlyContentColumns) or structural, so an unclassified new
  content column fails CI (LLP 0105 #graph-provenance).
- null-cwd-full-class.test.js (Finding 4): pins that a null-cwd
  ai_gateway_messages row is visible (query seam, incl. the fail-closed
  unknown caller) and forwarded (export seam) - full-class by design,
  not a leak (LLP 0105 #unknown, LLP 0070 #enforce).

Finding 2: the SKILL.md durability copy ("once `ignore`d, the capture
seam blocks re-import, so the purge is durable") is now TRUE and
consistent with classification.js:57; left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral review - round 1 (head 51740f5)

Verdict at review time: block. One blocker + one cascading major + two minor test-hardening items. All actionable findings were fixed on this branch (fixes landed at 3c7424a); the head has advanced, so a round-2 re-review runs at the new head.

BLOCKER (fixed) - T1/LLP 0103: capture seam never honored machine-local ignore

The five capture-seam resolver sites called createUsagePolicyResolver() with no machine-local list path, and localOnlyListPath appeared nowhere under plugins-workspace. So hyp ignore --private (the primary path, since marking verbs forbid repo dotfiles per LLP 0071/R6) silently failed to stop recording: sessions in a --private directory were recorded to the local cache and re-imported by hyp backfill after a purge, defeating the class promise "do not record this folder's sessions at all" (src/core/usage-policy/classification.js:57).

  • Fix: threaded the machine-local list into all five sites - hypaware-core/plugins-workspace/claude/src/{projector.js,settle.js,backfill.js} and codex/src/{exchange-projector.js,backfill.js}, wired from each plugin's index.js activate() via localOnlyListPath(ctx.paths.stateDir), matching the export/query/purge seams. @ref LLP 0103 [implements] added at each site.
  • Correctness guard: the drop gate stays class-aware (policy.class === 'ignore'), so ignore drops at capture while local-only still records (withheld later at the export seam). New test test/plugins/capture-seam-machine-local-list.test.js asserts both polarities for the claude and codex projectors.

MAJOR (resolved by the blocker fix) - T9/LLP 0100 §skill

Both hypaware-privacy SKILL.md files (claude:132, codex:160) promised "the capture seam blocks re-import, so the purge is durable" - false under the gap. With the blocker fixed the copy is now true; wording left unchanged (verified consistent with classification.js:57).

MINOR (test added) - T4/LLP 0105: graph content-column allowlist decoupled from schema

context-graph/src/datasets.js hand-maintains the suppressed-content allowlist; a future content column added without classifying it would leak to restricted callers. New guard test test/plugins/context-graph-content-column-allowlist.test.js fails CI if any schema column is left unclassified.

MINOR (test added) - T4/LLP 0105: null-cwd rows are intentionally full-class

src/core/query/visibility.js passes null-cwd rows through whole (intended: matches the export seam's treatment of unattributable rows). New test test/core/null-cwd-full-class.test.js pins this polarity so it is not later mistaken for a leak. Behavior unchanged.

Note on CI

The fix commit's local suite showed 8 pre-existing chunks is not async iterable failures from a squirreling/node_modules version mismatch in the isolated worktree (they reproduce with the fix reverted, and the original head's CI was green on the same suite). CI on 3c7424a is the authority; round 2 gates on it.

Reviewed by neutral (Claude-family; the Codex half does not run under this repo's gateway). Held for a human - neutral never merges.

…ir (LLP 0103)

The round-1 capture-seam wiring resolved the machine-local usage-policy
list at localOnlyListPath(ctx.paths.stateDir), the per-plugin dir
(<stateRoot>/plugins/<name>). The list lives at the shared state root
(<stateRoot>/usage-policy/local-only.json), so readListEntriesSync found
nothing, the resolver stayed blind, and `hyp ignore --private <dir>` never
stopped recording at capture. Derive the list path the way purge.js, the
export seam (activation.js), and the query seam (visibility.js) do:
localOnlyListPath(readObservabilityEnv(ctx.env).stateDir).

Add activate()-driven coverage that stages a per-plugin ctx.paths.stateDir
while writing the list at the shared root, so it fails against the old
wiring and passes after the fix. The prior tests passed the correct path
straight to the factories, bypassing activate(), which masked the bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral review - round 2 (head 3c7424a)

Verdict at review time: block. Round 2 re-reviewed the round-1 capture-seam fix and found it was inert: the resolvers were wired to localOnlyListPath(ctx.paths.stateDir), but ctx.paths.stateDir is the per-plugin dir <stateRoot>/plugins/<name> (src/core/runtime/paths.js:49), while the machine-local list lives at the shared state root <stateRoot>/usage-policy/local-only.json. The list file never existed at the wired path, so the resolver stayed blind and hyp ignore --private still recorded at capture - the round-1 blocker persisted (withheld off-machine at the export/query seams, but still written to local disk, against LLP 0103's capture-seam intent). The masking cause: the round-1 test passed the correct path directly to the factories, bypassing activate(), so it stayed green while activate() derived the wrong path.

BLOCKER (fixed at 2b6a456) - stateDir derivation

  • Fix: both hypaware-core/plugins-workspace/{claude,codex}/src/index.js now derive localOnlyListPath(readObservabilityEnv(ctx.env).stateDir) - the shared state root, matching purge.js:49-50, the export seam (activation.js), and the query seam (visibility.js).

MINOR (fixed at 2b6a456) - test could not catch the derivation bug

  • The test/plugins/capture-seam-machine-local-list.test.js now drives the real activate(ctx) (ctx.paths.stateDir per-plugin, list written at the shared root) and asserts an ignore dir DROPS and a local-only dir still RECORDS, for both clients. Fail-before/pass-after confirmed: with the buggy ctx.paths.stateDir wiring reverted, exactly the two new activate()-driven ignore-drop tests fail; with the fix, all 8 pass. The misleading docstring was corrected.

The head has advanced to 2b6a456; a round-3 re-review runs at the new head once CI is green. Cap is 4 rounds. Held for a human; neutral never merges.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral review - round 3 (head 2b6a456)

Clean. Round 3 verified the round-2 stateDir fix at the current head. No actionable findings.

Delta reviewed (3c7424a..2b6a456, 3 files):

  • hypaware-core/plugins-workspace/{claude,codex}/src/index.js now derive the machine-local list path from localOnlyListPath(readObservabilityEnv(ctx.env).stateDir) - the shared state root. This is verbatim the derivation used by the production reader src/core/commands/purge.js:49-50, and resolves to the same <stateRoot> the export and query seams read; the prior per-plugin ctx.paths.stateDir (where the list file never exists) is gone.
  • test/plugins/capture-seam-machine-local-list.test.js drives the real activate(ctx) (per-plugin ctx.paths.stateDir, list written at the shared root) and asserts ignore drops / local-only records for both clients. This closes the round-1 masking gap; confirmed fail-before/pass-after against the buggy wiring.

CI green at this head (typecheck + test SUCCESS). Mergeable, reviewed. Terminal: held as a ready PR for a human to merge - neutral never merges.

Review lineage: round 1 found the capture-seam blocker (fixed at 3c7424a); round 2 found that fix inert - wrong stateDir (fixed at 2b6a456); round 3 confirms clean. maxReviewRounds=4.

@philcunliffe philcunliffe marked this pull request as ready for review July 14, 2026 00:43
@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 14, 2026
@philcunliffe philcunliffe merged commit 41ea01c into master Jul 14, 2026
4 checks passed
@philcunliffe philcunliffe deleted the integration/enrollment-privacy-review branch July 14, 2026 19:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant