Skip to content

hyp policy answers in the vocabulary it teaches - #411

Merged
philcunliffe merged 3 commits into
masterfrom
fix/issue-393
Jul 28, 2026
Merged

hyp policy answers in the vocabulary it teaches#411
philcunliffe merged 3 commits into
masterfrom
fix/issue-393

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Before

$ hyp policy set /tmp/proj sync
marked /tmp/proj as full (/tmp/home/hypaware/usage-policy/local-only.json)
$ hyp policy set /tmp/proj sync
already full (governed by /tmp/home/hypaware/usage-policy/local-only.json)
$ hyp policy show /tmp/proj
path: /tmp/proj
ignored: no
class: full
source: machine-local
governed-by: /tmp/home/hypaware/usage-policy/local-only.json
residual-cached-rows: 0
$ hyp policy list
/tmp/proj: full (sync)
(/tmp/home/hypaware/usage-policy/local-only.json)
$ hyp policy unset /tmp/proj sync
removed 1 full entry: /tmp/proj
$ hyp policy unset /tmp/proj sync
not full (no machine-local full entry governs /tmp/proj)

After

$ hyp policy set /tmp/proj sync
marked /tmp/proj as sync
$ hyp policy set /tmp/proj sync
already sync (governed by machine-local policy store)
$ hyp policy show /tmp/proj
path: /tmp/proj
ignored: no
class: sync
source: machine-local
governed-by: machine-local policy store
residual-cached-rows: 0
$ hyp policy list
/tmp/proj: sync
(policy store: /tmp/home/hypaware/usage-policy/local-only.json)
$ hyp policy unset /tmp/proj sync
removed 1 sync entry: /tmp/proj
$ hyp policy unset /tmp/proj sync
not sync (no machine-local sync entry governs /tmp/proj)

A governing .hypignore is still named by its real path in policy show:
that is a file the user can open and edit, not an internal.

Root cause

src/core/commands/policy.js maps the public token sync onto the stored
class full at the parser edge (TOKEN_TO_CLASS), and then hands the stored
class to the shared writers, which print internals verbatim:

  • src/core/commands/clients.js:905 (pre-fix) wrote the confirmation as
    the template marked <resolvedTarget> as <targetClass> (<listPath>), where
    targetClass is the stored full and listPath is the backing
    usage-policy/local-only.json.
  • src/core/commands/clients.js:890 (pre-fix): already ${existing.class} (governed by ${existing.governedBy}).
  • src/core/commands/clients.js:1089,1091 (pre-fix, runIgnoreCheck, shared
    with policy show): class: ${result.class} and
    governed-by: ${result.governedBy}.
  • src/core/commands/clients.js:1022,1026 (pre-fix, runUnmarkMachineLocal):
    the removal and no-op lines print targetClass / entry.class.
  • src/core/commands/policy.jsrunPolicyList printed full (sync) and the
    bare store path.

The mapping was one-way: public token in, internal class out. There was no
translation back at the human-output boundary.

Fix

The four policy runners now pass a PolicyHumanVocabulary (new interface in
src/core/commands/types.d.ts) into the shared writers. It renders a stored
class as its CLI token, names the machine-local store instead of pathing it,
and drops the trailing store path from the set confirmation. The writers'
default is INTERNAL_VOCABULARY (internals verbatim), so the deprecated
hyp ignore / hyp unignore flag aliases keep their exact legacy output by
construction rather than by test discipline (LLP 0111 #aliases). LLP 0111
#tokens / #show / #list / #aliases are updated in this commit to describe the
bidirectional mapping and to record the alias divergence as deliberate.

Regression test

test/core/policy-command.test.js (5 new tests, plus 6 existing pins that had
encoded the buggy wording). Run on the pre-fix tree, node --test test/core/policy-command.test.js gives # pass 22 / # fail 9, for example:

not ok 7 - hyp policy set <path> sync confirms in the public vocabulary and never names the backing store file
error: |-
Expected values to be strictly equal:
+ actual - expected
+ 'marked /tmp/hyp-policy-repo-kZOlwe as full (/tmp/hyp-policy-home-EPYkk7/hypaware/usage-policy/local-only.json)\n'
- 'marked /tmp/hyp-policy-repo-kZOlwe as sync\n'
not ok 14 - hyp policy show (human) renders a stored full entry as sync and names the store neutrally
error: |-
The input did not match the regular expression /^class: sync$/m. Input:
'path: /tmp/hyp-policy-repo-cuphgM\n' +
'ignored: no\n' +
'class: full\n' +
'source: machine-local\n' +
'governed-by: /tmp/hyp-policy-home-WLRumf/hypaware/usage-policy/local-only.json\n' +
'residual-cached-rows: 0\n'

After the fix: # tests 31 / # pass 31 / # fail 0.

The --json contract and the on-disk format are untouched

Evidence, all in-tree and all green:

  • hyp policy show --json keeps the stored vocabulary and the store path (unchanged machine contract) (new): asserts the stdout of
    policy show --json is byte-equal to ignore --check --json, and that the
    payload still carries class: "full" and governedBy: <.../local-only.json> for a stored full entry.
  • The pre-existing policy show [path] --json is byte-compatible with hyp ignore --check --json and both policy list --json tests are unchanged and
    still pass; runPolicyList's --json branch returns before any human
    rendering.
  • Every policy set test still asserts the store contents via
    readLocalOnlyEntries, e.g. set <p> sync still writes
    [{ dir, class: 'full' }]. No store read/write helper, no store version,
    and no class lattice code was touched.
  • Two new exact-output tests in
    test/core/ignore-private-sync-command.test.js pin the deprecated aliases:
    hyp ignore --sync still prints marked <p> as full (<listPath>) and
    hyp ignore --check still prints class: full with the real store path.
    They pass both before and after this change.

Suite status

npm test: 2675 tests, 2666 pass, 8 fail, 1 skipped. The 8 failures are the
pre-existing test/core/leave-command.test.js ones (verified by stashing this
branch's changes and re-running that file on the base commit: 11 tests, 3
pass, 8 fail, identical set). npm run typecheck is clean. npm run smoke -- local_only_export_withhold passes.

Fixes#393

neutral-loopand others added 2 commits July 27, 2026 22:46
`hyp policy set <p> sync` confirmed with `marked <p> as full
(.../usage-policy/local-only.json)`: the internal class and the backing store
file, leaked into the one line whose whole job is to confirm the word the user
typed. It reads as though `sync` had become `local-only`, the exact inversion
LLP 0110 minted this verb to kill.
The `policy` runners now pass a `PolicyHumanVocabulary` into the shared
marking / unmarking / check writers, so `set`, `show`, `unset`, and `list`
answer in the sync|local-only|ignore token vocabulary and name the
machine-local policy store instead of pathing it. A governing `.hypignore` is
still named by its real path: it is a file the user can edit, not an internal.
The writers' default vocabulary is the verbatim-internals one, so the
deprecated `hyp ignore` / `hyp unignore` flag aliases keep byte-identical
output by construction (LLP 0111 #aliases), now pinned by exact-output tests.
The on-disk `full` class, the `local-only.json` store, and every `--json`
shape are untouched.
Co-Authored-By: Claude <noreply@anthropic.com>
…a corrupt store
Review fixes for #411 (issue #393):
- policy show now suffixes the class label with "(implicit default, not yet
classified)" when nothing governs the directory, gated through a new
optional PolicyHumanVocabulary#implicitSuffix member that defaults to a
no-op so the deprecated `--check` alias keeps its byte-identical bare
`class: full` output. Without this, an unmarked directory rendered
identically to an explicit `sync` mark, which the hypaware-privacy skill
could misread as a recorded consent answer.
- policy show / policy list catch LocalOnlyListUnreadableError at the policy
edge and report "the machine-local policy store at '<path>' is unreadable
or malformed" instead of leaking the resolver's "local-only list" wording;
hyp status and the flag aliases are untouched.
- policy set now suffixes its confirmation with "(machine-local policy
store)", matching the no-op and policy list lines that already said the
marking never leaves the machine.
- LLP 0111 #tokens/#show updated to document both behaviors.
--json output, the on-disk store format, and all three deprecated alias
forms remain byte-identical.
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Review round 1 - 72285ef

Verdict: findings, no blockers. All four fixed and pushed as 8a1da2d.

The reported bug is fixed on every policy human surface, and the two compatibility constraints hold: the --json contract and the on-disk store are byte-identical to master. The findings were about a surface the fix reached but got slightly wrong, and two it did not reach.

The finding worth reading

1. policy show rendered a never-classified directory as class: sync, the same token an explicit sync marking prints.clients.js:1110 routed result.class through the vocabulary unconditionally, but the resolver returns class: 'full', governedBy: null for an unmarked directory (usage-policy/matcher.js:144). So:

$ hyp policy show <unmarked> $ hyp policy show <marked sync>
class: sync class: sync
source: none source: machine-local

Before this PR that line read class: full, which also failed to distinguish the two, but full is not a token a user can type so it could not be misread as a recorded decision. sync can. That inverts the safety default in a way the old wording did not.

The concrete hazard: the hypaware-privacy skill (plugins-workspace/claude/skills/hypaware-privacy/SKILL.md:118) instructs an agent to run hyp policy show <dir> and report the governing source and class. On a never-classified directory holding sensitive history, an agent reading class: sync concludes the user already chose to ship it and drops it from the review list. The first-sync hold expires and the directory forwards without ever having been offered as a choice. LLP 0106 and LLP 0103 make exactly this distinction load-bearing: the classification hook prompts on governedBy === null and stays silent on an explicit full.

Fixed: the class line now reads class: sync (implicit default, not yet classified) when governedBy is null. The suffix is a new optional implicitSuffix() member on PolicyHumanVocabulary (types.d.ts:26), so INTERNAL_VOCABULARY needs no change and the deprecated alias stays byte-identical by omission rather than by remembering to pass something. That preserves the structural property this PR's design already got right. LLP 0111 #show and #tokens document the case, which the original edit did not mention.

The rest

2. The corrupt-store error path still printed both internals the PR removed.usage-policy/local_only.js:36 throws a message naming the store path and calling it "the local-only list", uncaught at policy.js:299 and clients.js:1082:

$ hyp policy show <p>
hyp policy show: local-only list at '<path>' is unreadable or malformed [exit 1]

A user who ran hyp policy set X sync and later hit a truncated store was told their sync marking lives in "the local-only list" - the exact inversion issue #393 reports, on the one verb that exists to avoid it. Fixed by catching LocalOnlyListUnreadableError at the policy edge only (policy.js:98, wired into runPolicyShow and runPolicyList), emitting error: the machine-local policy store at '<path>' is unreadable or malformed at exit 1. The path is still printed, since the user needs to know which file to repair. hyp status and the aliases keep their original wording, verified against a corrupt store.

3. Two changed human lines had no test. The unmarked-directory output was asserted only through --json, and the empty-store policy list assertion matched /no machine-local entries/ loosely enough that the new (policy store: <path>) label could regress silently. Fixed: added the human assertion for the implicit-default line, tightened the empty-store test to the full labelled line, added two corrupt-store tests, added a policy set <p> ignore test pinning the store suffix, and added an alias test proving hyp ignore --check on an unmarked directory still prints bare class: full with no suffix.

4. policy set was the only surface that stopped saying the marking is machine-local (policy.js:79, storeSuffix: () => ''), while the no-op path and policy list both still named the store. LLP 0111 #set makes "machine-local, never a .hypignore dotfile" a defining property, so after hyp policy set X ignore the user had no confirmation that nothing was written into their repo. Fixed:marked <p> as sync (machine-local policy store), LLP #tokens updated to match.

Verified sound

The --json contract is genuinely untouched, checked by construction rather than by reading: a master-behavior tree was built by overlaying git show origin/master: versions of policy.js, clients.js, and types.d.ts onto the worktree, an identical 20-step CLI script was run against each with a fresh HYP_HOME, and the outputs diffed with run paths normalized. Changes appear only in the six human-output blocks. Every --json block, every alias block, the corrupt-store block, and the help block are byte-identical. policy show --json still emits "class":"full" and "governedBy":<path>; policy list --json unchanged.

On-disk format unchanged: after policy set <p> sync the raw store is {"version":2,"entries":[{"dir":"<p>","class":"full"}]}.

Completeness. Swept every human-output site across src/, bin/, hypaware-core/, docs/, .claude/, and the plugin skills. The only sites rendering a stored class to a human are policy.js:311, clients.js:908, and clients.js:1110, all vocabulary-routed. hyp status reports only localOnlyDirCount, hyp purge filters on class === 'full' without printing it, both hypaware-privacy SKILL.md copies and the classification hook already teach tokens only, and README plus docs/PRIVACY.md are token-only. The one leak found was finding 2.

Forward compatibility.TOKEN_TO_CLASS and CLASS_TO_TOKEN are exact inverses over the three-element lattice, and an unknown legacy class never reaches the renderer: readLocalOnlyEntries schema-validates against USAGE_CLASSES and throws. Confirmed with a store entry "class":"quarantine": show and list exit 1 identically on branch and master, no undefined, no crash.

Conventions. Zero U+2014 in the diff (checked again after the fix commit), no semicolons, an interface rather than @typedef, no inline import() types, both new JSDoc @imports repo-root-anchored. All @ref anchors (#tokens, #aliases, #surface, #show, #unset, #list, #set) resolve in LLP 0111, and npm run build:types emits a correctly root-anchored declaration.

The alias divergence: deliberate and correct

hyp ignore --sync still prints marked <p> as full (<listPath>). LLP 0111 #aliases reads "Output-identical: the aliases keep their exact stdout/stderr and exit codes ... so every existing test passes unchanged", scoped to output and justified by no-breaking-release for existing callers. The same section pins deprecation as help-text-only and explicitly rejects a runtime nag as risky for scripted callers. So the two-vocabulary state exists only on a deprecated spelling that no product surface teaches, and it is enforced structurally rather than by discipline. The residual cost is real and permanent until a breaking change the doc defers on purpose, but it is the cost the design already chose. No change wanted.

Verification run

At the fixed head 8a1da2d: node --test test/core/policy-command.test.js 35 pass, test/core/ignore-private-sync-command.test.js 17 pass, 0 fail across both. npm test 2671 pass, 8 fail, all in test/core/leave-command.test.js, still the only failing file and the count did not grow from master's pre-existing 8. npm run typecheck clean. npm run build:types exit 0.

Real CLI against a temp HYP_HOME after the fixes:

policy show <unmarked> class: sync (implicit default, not yet classified) / source: none
policy show <marked> class: sync / governed-by: machine-local policy store
policy set <p> sync marked <p> as sync (machine-local policy store)
policy set <p> sync already sync (governed by machine-local policy store) [no-op]
policy list <dir>: sync + (policy store: <path>)
policy unset <p> removed 1 entry: <p> (sync)
corrupt store error: the machine-local policy store at '<path>' is unreadable or malformed [exit 1]
ignore <p> --sync marked <p> as full (<listPath>) [byte-identical to master]
ignore --check class: full / governed-by: <listPath> [byte-identical to master]
unignore <p> --sync removed 1 full entry: <p> [byte-identical to master]
hyp status (corrupt) local_only_list_unreadable: local-only exclusion list at '<path>' [unchanged]

The head has moved to 8a1da2d, so the next tick re-reviews at that head. Held for a human either way: neutral does not merge.

runPolicyShow and runPolicyList already caught
LocalOnlyListUnreadableError and reported it with the neutral
"machine-local policy store" wording; runPolicySet and runPolicyUnset
called the shared writers bare, so the error fell through to the
generic dispatcher and printed the writers' internal "local-only list"
wording plus the store path unprefixed - the exact leak issue #393
minted this verb to stop. Wrap all four `policy` runners uniformly,
add corrupt-store tests for set/unset mirroring the existing show/list
pair, and correct two doc overstatements: the runners-only scope of
reportUnreadableStore now names all four runners, and LLP 0111's claim
that no policy line ever names the store path is corrected since the
corrupt-store message and `policy list`'s trailing parenthetical both
do, deliberately.
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Review round 2 - 8a1da2d

Verdict: findings, no blockers. Fixed and pushed as a541d62.

All four round-1 fixes verified sound and all three hard constraints re-verified. One finding: round 1's fix 2 was half-applied.

The finding

reportUnreadableStore (policy.js:98) was wired into only runPolicyShow (:249) and runPolicyList (:328). runPolicySet (:218) and runPolicyUnset (:287) called the shared writers bare, so LocalOnlyListUnreadableError escaped to the generic dispatcher (cli/dispatch.js:361), which prints hyp <verb>: <err.message> - and that message (usage-policy/local_only.js:36) names the store path and calls it "the local-only list". Verified against the real CLI with a truncated store:

hyp policy show <dir> exit=1 error: the machine-local policy store at '<path>' is unreadable or malformed
hyp policy list exit=1 error: the machine-local policy store at '<path>' is unreadable or malformed
hyp policy set <dir> sync exit=1 hyp policy set: local-only list at '<path>' is unreadable or malformed
hyp policy unset <dir> exit=1 hyp policy unset: local-only list at '<path>' is unreadable or malformed

Two of four subcommands fixed and two not is worse than uniform. A user whose store was truncated by a crashed write runs hyp policy set ~/work sync and the one verb minted to stop naming internals answers with both of them, in the exact "local-only" wording that reads as "your sync became local-only". That is issue #393 itself, resurfacing on the error path.

Two doc statements were also inaccurate as shipped: policy.js:89-91 claimed the catch was "scoped to the policy runners only" (it was scoped to two of them), and llp/0111:62-63 claimed "every human line a policy subcommand prints ... never names the store's backing file path", which is false twice over - the corrupt-store message deliberately prints the path (you need to know which file to repair), and policy list prints (policy store: <path>), which the doc's own bullet describes three lines later.

Fixed in a541d62: the same catch now wraps all four runners (verified: four reportUnreadableStore call sites at policy.js:225,260,306,346), two corrupt-store tests added mirroring the existing show/list pair, the comment names all four runners, and the LLP clause is reworded to say no policy line calls the file "the local-only list" while the path itself is named with a neutral label where the user needs it.

Constraint re-verification

Method: a master-behavior tree was built by overlaying git show origin/master: versions of policy.js, clients.js, and types.d.ts onto a copy of the head, then 60 real CLI invocations were run against each tree with an identical fresh HYP_HOME and identical sandbox paths, capturing stdout, stderr, exit code, and the on-disk store, and diffed.

ConstraintResult
policy show --json byte-identicalPASS, 0 hunks across unmarked, full, local-only, ignore, .hypignore-governed, cwd-default
policy list --json byte-identicalPASS, 0 hunks across empty / one / two entries
On-disk store unchangedPASS, byte-identical including multi-entry and every class
hyp ignore --sync / --private / --local-onlyPASS, 0 hunks
hyp ignore --check on a marked dirPASS, class: full / governed-by: <listPath>
hyp ignore --check on an unmarked dir, no implicit suffixPASS - the exact path fix 1 touched
hyp unignore --syncPASS, 0 hunks
hyp status on a corrupt storePASS, still local-only exclusion list at '…'
Aliases on a corrupt storePASS, unchanged

The only behavioral diffs against master are the 17 intended ones.

"Correct by omission" holds structurally, not merely by test: the aliases never construct or pass a PolicyHumanVocabulary, so the parameter default INTERNAL_VOCABULARY applies, and the new optional implicitSuffix is absent by design and null-coalesced at its single call site. One latent hazard worth knowing: a future optional member used without a ?? noop at its call site would TypeError on the alias path rather than fall back. Worth a comment on the interface if more optional members land.

Fix-by-fix

  • Fix 1 (implicit default) is correctly gated. !result.governedBy is exactly "nothing governs this path": governedBy: null is produced at one place only (usage-policy/matcher.js:144), and an ancestor machine-local entry or a .hypignore both set it, so neither gets the suffix.
  • No machine parser exists for the class: line, checked before accepting the suffix: both hypaware-privacy SKILL.md copies reference hyp policy show in prose only, docs/PRIVACY.md is prose, and no smoke flow, hook, or script reads it. The only ^class: …$ regexes in the tree are in tests. A ^class: (\w+)$ parser would have broken, but none exists.
  • Fix 2's blast radius is safe:err.filePath is always set at every construction site, the message is built from it rather than a re-derived path, exit code stays 1, and the change only adds catches (nothing previously surfacing is now swallowed).
  • Test quality: each of the four fixes has a test that fails if reverted, including a negative alias guard asserting doesNotMatch(/implicit default/). The alias-stability tests use exact-string rather than loose-regex assertions.

Verification run

node --test on the two policy test files: 52 pass, 0 fail (35 + 17). npm test: 2680 tests, 2671 pass, 8 fail, all in test/core/leave-command.test.js, the only failing file, count unchanged from master's pre-existing 8. npm run typecheck and npm run build:types both clean. After the round-2 fix: 54 tests in the two files, all passing, and the four policy subcommands now print the neutral wording uniformly while the three alias forms and hyp status are byte-identical to before.

The head has moved to a541d62, so the next tick reviews that head. Held for a human: neutral does not merge.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Triage: shippable

The review loop hit its round cap with this head unreviewed, so the remaining question was judged rather than re-reviewed: can this ship safely?

It can. All five findings from the two rounds were re-derived from the tree rather than taken on trust, and each fix is really there and really works. The never-reviewed head (8a1da2d..a541d62) was reviewed on its own merits: two narrow try/catch wrappers that catch only LocalOnlyListUnreadableError and rethrow everything else, two tests, a comment, and an LLP reword. Exit code is 1 in all four cases.

The three hard constraints were re-verified live against a from-scratch checkout of the pre-PR base run side by side, not by re-reading earlier claims:

  • policy show --json still emits "class":"full" with the real governedBy path
  • the on-disk store is still {"version":2,"entries":[{"dir":…,"class":"full"}]}
  • the deprecated aliases still print their old internals-leaking output byte-for-byte, including on a corrupt store

Interaction hunting found nothing: governor naming stays correct across both branches of runMarkMachineLocal, unset's no-op wording is class-token-correct in both forms, hyp status is untouched, and no output-parsing code anywhere in the repo could break on the new implicit-default suffix.

One item was deferred to #413: the corrupt-store catch no longer reaches the dispatcher's generic catch, so it loses that catch's error_kind span attribute for this failure mode. The message, exit code, and span status are all still correct, so this is telemetry fidelity rather than behavior.

npm test: 2682 tests, 8 failures, all pre-existing in test/core/leave-command.test.js and failing identically on the base. npm run typecheck clean.

Held for a human. neutral does not merge.

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 28, 2026
@philcunliffe
philcunliffe merged commit d05afff into masterJul 28, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-393 branch July 28, 2026 05:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral 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.

Policy confirmation reports sync as full and exposes local-only.json

1 participant

@philcunliffe