Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ Rows are ordered by **acceptance date**, not by D-number. A few early rows (D-03
| D-084 | ADR-to-ledger bidirectional integrity repair. (a) Markdown render: twelve blank lines inside the Accepted table (former lines 138-158 and 186) split it into thirteen fragments, so every row from D-042 onward — the whole M1/M2 arc, all tool-baseline re-baselines, and the ADR 0073-0078 acceptances — rendered as literal pipe text on github.com. Blanks removed; a note above the table records that rows are ordered by acceptance date, not by D-number. (b) Status flips: ADRs **0002** (runtime), **0003** (transport topology) and **0008** (dashboard auth) each read `status: proposed` while this ledger listed them Accepted at D-002, D-009 and D-015 respectively, all dated 2026-04-22. All three flipped to `accepted`; each decision is accepted **as written**, with no change to its recommendation. (c) Backfilled references: ADRs **0009** (ontology versioning), **0013** (forbidden tier split), **0014** (InterventionRecord) and **0039** (charter v1.4.0 invariants 18+19) are `status: accepted` but appear nowhere in this ledger; they are recorded here rather than given four retroactive rows, because inventing dated acceptance rows for decisions whose real acceptance dates are unrecoverable would fabricate provenance. ADR 0039 is the sharpest case — the charter change log credits invariants 18 and 19 to it, and both are live charter text. The `Decision ledger:` lines in ADRs 0013 and 0014 cited unrelated rows (D-025/D-026/D-032) and now point here. | The ledger is the human-readable decision record and the repo is a public portfolio surface; a table that does not render defeats both. The three flips close a contradiction where the ADR and the ledger disagreed about the same decision — a reader following either one alone got a different answer. Phase 1 Thread B, the accept trigger both 0002 and 0003 named, is retired as a decision gate rather than run: for 0002 the installed runtime settles the axis Thread B was scoped to measure (observed 2026-07-25 on this host, charter inv. 14: `node --version` = v24.18.0; `node:sqlite` exports DatabaseSync/StatementSync/Session/constants/backup; `process.versions.sqlite` = 3.53.1 — Node ships SQLite in-core with the synchronous single-writer API ADR 0077 needs), and for 0003 no finding could argue against localhost-only, which is the conservative option and already the operative posture. ADR 0008 is recorded as a decision, NOT as construction: no dashboard exists, Ring 3 is `.gitkeep`-only, and accepting it authorizes no implementation and starts no milestone. Rows are NOT reordered — D-037..D-044 were accepted out of numeric order and the ledger is append-only; rewriting recorded history for a cosmetic property is a worse defect than the one it fixes. | ADR 0002 / 0003 / 0008 status flips; ADR 0009 / 0013 / 0014 / 0039 reference backfill | 2026-07-25 |
| D-085 | `PLAN.md` collapses 1,579 -> ~290 lines: the ~1,190-line superseded prior-focus stack is removed (preserved at `3063d9f`; `git show 3063d9f:PLAN.md`) and §Current Focus is capped at 20 lines. Milestones 0-6 are **retained** with their acceptance criteria; two demonstrably false statements inside them are corrected — the M3 hash bullet, which specified naive concatenation that `adr/0064:453-456` forbids, and eight scoped `just test` invocations naming targets the justfile rejects. Three checklist rules and regression trap #61 are added, earned by the withdrawn attempt described below. | The prior-focus prose is narration, not obligation: its decisions are in this ledger, its designs in `adr/`, its status in the dated readouts, so a third copy is pure maintenance cost. The milestone blocks were NOT deleted because the ground for doing so did not survive checking — `adr/0010:19` enumerates M4's five tools in accepted text, and `adr/0004` §References anchors M3's `facts`/`fact_observations` to the external research plan, so "no matching ADR" was false for both; correcting the two provably wrong statements removes the implementer trap without a claim that fails scrutiny. **Withdrawn from this change:** an earlier commit (`3e9b314`, dropped, never pushed) also redefined change class I by callability. Five reviewer lenses found it unmergeable — it quoted `adr/0057:422-423` as authorizing an ApprovalGrant mint when that line sits inside "This ADR explicitly does not authorize:" whose final bullet is "Ring 1 service implementation code"; it restated `charter:54`'s "is live" as "exist", weakening a binding invariant; it claimed "supersedes no ADR" while contradicting `adr/0077`'s nine class-I self-classifications; and it placed write paths in a class labelled "kernel read path". It is relocated, not rejected: the audit-append relief belongs in the PR that implements the audit append, where code exercises the wording. Nothing is blocked — the policy-snapshot loader stands on `adr/0060:325-331` and `adr/0061:371`. | Trap #61; `adr/0064:452-456`; `adr/0010:19`; `adr/0004` §References; no ADR superseded | 2026-07-25 |
| D-086 | Ring-1 policy-rule loader lands at `packages/kernel/src/policy/rule-loader.ts` under **ADR 0079**, scoped to rule shape only: parse, project `operation_class_defaults` into `PolicyRule`, validate against `policyRuleSchema`, assert exact `operation_class` set equality both ways. Provenance/digest verification is **explicitly out of scope** and stays with `scripts/ci/snapshot-binding-check.sh`. Every projected value is read from a field policy declares; where policy is silent the loader rejects, with one recorded authored default (`approval_path_allowed` when the tier does not declare it — provably inert, since the schema pins it to `true` on the only branch where it is consulted). | Supersedes a withdrawn attempt (`3e9b314`-lineage, PR #98, converted to draft, never merged) that combined provenance verification with projection. Five reviewer lenses found it unmergeable on two counts. **First, it was not authorized:** it cited ADR 0060 §Provenance verification, while that ADR's §Out of scope declines to authorize "the §Provenance-verification implementation" and defers it to a follow-up gateway ADR that does not exist — the same failure class as trap #61, one day after that trap was seeded, which proved the rule too narrow (it named a location, not a property; now generalized in `IMPLEMENT.md` §Per-PR checklist). **Second, it failed open:** it derived approval posture from the PRESENCE of an `approval_required_details` block, and in YAML `approval_required_details:` with no value parses to `null`, so "not a mapping" read as "absent" and absent projected `approval_required: false` on a `write-destructive` class — while the CI checker it proposed to replace fails closed on the same bytes. That is the fifth instance of one repo-wide pattern (boundary-check rc=2 read as pass; `no-live-secrets` malformed pattern read as clean; the justfile's missing toolchain read as exit 0; the Phase-0b hook's missing deny path read as allow), so the test suite now carries a **fail-closed class** that feeds the null / absent / wrong-type variant of every security-relevant field and asserts rejection. Investigating it established that live policy declares approval posture in **three** places (`tiers.<tier>`, `operation_class_defaults.<class>`, and that class's `approval_required_details.status`) and that `valid_until_ceiling` is declared per tier and per class — values the withdrawn draft authored as `'not_applicable'` rather than read. A schema change was considered and rejected as unnecessary: `policyRuleApprovalSchema` already rejects absent, `null`, and `"false"`; the defect was that the loader never passed policy's declared value to it. Retiring the Ruby lane is NOT part of this decision — with provenance out of scope the two checkers answer disjoint questions, so no inv-1 duplication surface exists. | ADR 0079 (ships with this code per D-085's successor rule); ADR 0060 §Out of scope (why provenance is deferred); trap #61 second occurrence | 2026-07-25 |
| D-087 | Read-only CLI adapter surface lands under **ADR 0080**: `packages/adapters/cli/` with one substantive verb, `hcs policy status`, invoked as `just cli policy status`. Discharges ADR 0079's precondition by adding a kernel-resolved `loadBoundPolicyRules()` that takes no path; the parameterized form stays for tests. Two gate defects were found and fixed because this is the first Ring-2 code to exist: `boundary-check.sh` rule 2 rejected the charter-legal `@hcs/kernel/api` import (its subtraction required a trailing slash, matching only deeper paths) and then flagged its own documentation (stage 1 matched any occurrence of the token, including backticked prose); the justfile's scoped-test discovery missed nested packages, so `just test cli` could not find `packages/adapters/cli/tests`. | No ADR authorized a CLI surface — ADR 0003 covers stdio/Streamable HTTP transports, and ADR 0079 §Out of scope excludes "any consumer of the returned rules," which a verb rendering those rules is. Citing either would have been the third occurrence of trap #61; applying the generalized citation rule prospectively caught it before a false citation was written, which is the first time that rule has prevented rather than diagnosed. The adapter decides nothing (inv. 1): it formats the loader's result, contains no tier literal, and a guard derived from `policyRuleTierSchema.options` asserts so over adapter source. Dispatch is an exhaustive match over a closed verb list — unknown verb exits 2, rejected snapshot exits 1 — rather than a lookup with a default handler. `run(argv)` returns `{exitCode, lines}` so the verb is testable without spawning a process. `resolveBoundSnapshotPath()` reads `HCS_ROOT` and falls back to the kernel module's own location, because charter inv. 15 warns GUI apps, launchd jobs, and IDE extensions do not inherit shell env. Rule 2 is now load-bearing for the first time: it is `[ -d packages/adapters ]`-guarded, so until this package existed it had no subject — and its first real subject exposed two false-positive bugs in the repair that landed in #93. | ADR 0080 (ships with this code per D-085's successor rule); ADR 0079 §Out of scope (the precondition discharged); charter inv. 1 / 7 / 15 | 2026-07-25 |

## Reversed

Expand Down
4 changes: 2 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ readouts; decisions belong in `DECISIONS.md`. This section states status only.
- **M1 COMPLETE** — 22/22 canonical Ring-0 entities, plus post-M1 `Model`
(ADR 0076 / D-077) and the ADR 0078 / D-081 `model_ref` slice.
- **Versions:** ontology **v1.34.0** · registry **v0.4.37** · charter **v1.6.0** ·
ADRs **0001–0079** (0026 absent, reserved — see `adr/0025` §Future amendments) ·
decisions through **D-086** (next-free **D-087**).
ADRs **0001–0080** (0026 absent, reserved — see `adr/0025` §Future amendments) ·
decisions through **D-087** (next-free **D-088**).
- **M2 residue:** the ADR 0034 boundary-evidence consumption matrix
(`Decision`/`ApprovalGrant` × {stale, missing, contradictory}) is the one
acceptance bullet still open. PolicyRule schema ref (D-078), freshness windows
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
---
adr_number: 0080
title: Read-only CLI adapter surface
status: proposed
version: v1
date: 2026-07-25
charter_version: 1.6.0
tags: [ring-2, adapter, cli, read-only, class-e, adr-0079-followup]
---

# ADR 0080: Read-only CLI adapter surface

## Status

`proposed`

Ships in the same PR as the code it governs, per D-085's successor rule.

## Date

2026-07-25

## Context

`packages/adapters/cli/` has held a `.gitkeep` since the repo was scaffolded.
No ADR authorizes a CLI surface: ADR 0003 covers transport topology (stdio +
Streamable HTTP), and ADR 0079 §Out of scope explicitly excludes "any consumer
of the returned rules" — which a CLI verb is. Citing either as authorization
would repeat regression trap #61 for a third time.

ADR 0079 also left a precondition on this PR: "A no-argument public form.
`LoadOptions` takes a caller-supplied path, which is acceptable while the only
callers are tests; before an adapter forwards a path argument, the public form
must become kernel-resolved."

The substrate has produced no runnable surface in its history. Everything built
so far is verified by tests and CI. That is not the same as an operator being
able to ask the substrate a question and read the answer.

## Decision

A read-only `hcs` CLI adapter at `packages/adapters/cli/`, with exactly one
substantive verb in this ADR's scope: **`hcs policy status`**.

**Read-only by construction, not by convention.** The adapter registers no
`Capability`, accepts and emits no `OperationShape`, mints and consumes no
`ApprovalGrant`, spawns no process, and writes nothing. Adding a verb that
mutates host state makes this package class I and gates it behind charter
invariant 7.

**The adapter decides nothing** (charter inv. 1). `policy-status.ts` formats the
loader's result object and does nothing else. It contains no tier name, no
classification branch, and no verdict of its own. A guard test asserts no tier
literal appears in adapter source, derived from `policyRuleTierSchema.options`
rather than hand-written.

**It imports only `@hcs/kernel/api`.** The kernel's exports map publishes that
path and nothing deeper, so a kernel internal is unreachable from Ring 2 at
module resolution — `ERR_PACKAGE_PATH_NOT_EXPORTED` — independently of whether
`boundary-check.sh` rule 2 is working.

**Dispatch is an exhaustive match over a closed verb list**, not a lookup with a
default handler. An unrecognised verb exits `2`. A rejected snapshot exits `1`:
a snapshot the kernel refuses is an operator-visible failure, not an
informational note.

**`run(argv)` is exported and pure** — it returns `{exitCode, lines}` rather
than writing to stdout — so the verb is testable without spawning a process or
capturing streams.

**Kernel-resolved snapshot path.** This ADR discharges ADR 0079's precondition:
`@hcs/kernel/api` now exports `loadBoundPolicyRules()`, which takes no path.
`resolveBoundSnapshotPath()` reads `HCS_ROOT` when set and falls back to the
kernel module's own location — charter inv. 15 warns that GUI apps, launchd
jobs, and IDE extensions do not inherit shell env, so a resolver that only read
the variable would fail in exactly those contexts. The parameterized
`loadPolicyRules` remains for tests, which must stage mutated snapshots.

## Out of scope

This ADR does not authorize:

- Any mutating verb, any verb that spawns a process, or any verb that writes to
disk or network.
- MCP tools, HTTP endpoints, or the dashboard. ADR 0003 governs transports;
those surfaces need their own ADRs.
- `hcs audit verify` or any audit-adjacent verb. The audit store does not exist,
and ADR 0077 §5 makes its chain-walk a kernel write path — a "read-only" verb
that induces a kernel write is not read-only.
- Gateway re-derivation, `Decision` construction, capability registration, tool
resolution, or host state. ADR 0057 §Out of scope defers all of these to ADRs
that do not exist.
- Installing `hcs` onto `PATH`. The verb is invoked through `just cli` in this
ADR's scope; a PATH install is a host-surface change with its own posture
questions.
- Machine-readable output. The current renderer is for a human reading a
terminal. A `--json` form is a contract, and contracts get ADRs.

## Options considered

**Fold the verb into ADR 0079.** Rejected: 0079's §Out of scope excludes
consumers of its own output, and amending an ADR to authorize the thing it just
declined is the shortcut D-085 recorded against.

**Skip the ADR and cite 0079 §Consequences**, which mentions this verb as
next work. Rejected on the generalized citation rule: a document mentioning
something is not the same as authorizing it. That rule exists because this
failure has now occurred twice.

**Ship a `--json` mode immediately.** Deferred. The first consumer is a human;
a machine format invites a parser, and a parser is a contract that outlives the
convenience.

## Consequences

An operator can run `just cli policy status` and read what the substrate knows
about the bound policy snapshot — the first invocable surface this project has
produced.

It also makes `boundary-check.sh` rule 2 load-bearing for the first time: that
rule is `[ -d packages/adapters ]`-guarded and scans adapters only, so until
this package existed it had no subject. The rule was repaired in PR #93 after
three months of never executing; this is the first code it actually polices.

Class **E** — adapter read path.

## References

- ADR 0079 §Out of scope — the kernel-resolved-path precondition this discharges
- ADR 0003 — transport topology; does not cover a CLI surface
- ADR 0057 §Out of scope — why gateway/capability/tool-resolution verbs are absent
- ADR 0077 §5 — why `hcs audit verify` is out of scope
- charter inv. 1 (adapters do not classify), inv. 7 (callability), inv. 15
(GUI/launchd do not inherit shell env)
- D-085 — the successor rule under which this ADR ships with its code
17 changes: 13 additions & 4 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,26 @@ typecheck: (_require-node-tool "tsc")
test target="": (_require-node-tool "vitest")
@echo "→ unit tests"
@if [ -n "{{target}}" ]; then \
if [ ! -d "packages/{{target}}/tests" ]; then \
echo "error: no test directory at packages/{{target}}/tests" >&2; \
dir=""; \
for d in packages/*/tests packages/*/*/tests; do \
[ -d "$d" ] || continue; \
[ "$(basename $(dirname $d))" = "{{target}}" ] && dir="$d"; \
done; \
if [ -z "$dir" ]; then \
echo "error: no test directory for target '{{target}}'" >&2; \
echo " available scoped targets:" >&2; \
for d in packages/*/tests; do [ -d "$d" ] && echo " - $(basename $(dirname $d))" >&2; done; \
for d in packages/*/tests packages/*/*/tests; do [ -d "$d" ] && echo " - $(basename $(dirname $d))" >&2; done; \
exit 1; \
fi; \
node_modules/.bin/vitest run "packages/{{target}}/tests"; \
node_modules/.bin/vitest run "$dir"; \
else \
node_modules/.bin/vitest run; \
fi

# Run the read-only CLI adapter. `just cli policy status`
cli *args:
@node --experimental-strip-types packages/adapters/cli/src/main.ts {{args}}

# Schema drift check
generate-schemas-check:
@bash scripts/ci/schema-drift.sh
Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Empty file removed packages/adapters/cli/.gitkeep
Empty file.
13 changes: 13 additions & 0 deletions packages/adapters/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@hcs/cli",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/main.ts"
},
"dependencies": {
"@hcs/kernel": "*",
"@hcs/schemas": "*"
}
}
Loading
Loading