# releases#196 — the access-allow POSTURE fork: census and a candidate ruling

STATUS 2026-08-21 (todlando, PORTER wave 3, subordinate lane): CENSUS COMPLETE,
NO BUILD, NO CODE. doyle scoped this as a ruling fork — census and a candidate
ruling only — and doyle/operator own the decision.

## Method and its limits, stated first

Every finding below is a SOURCE census read at `b88fab2a` (`git show` blobs, not
the working tree — the lane trees differ). NOTHING here was re-measured at
runtime, and no binary was built. The issue's original three-arm runtime
differential was measured at `cc2b09b`; #185's fix `f2d215a5` is an ancestor of
`b88fab2a` and it deliberately did not touch the precise arm's posture, so the
behavior claim carries forward — but it carries forward as a READ, not as a
measurement at this base. Finding 5 in particular is a source-derived defect
candidate and is labelled unmeasured where it appears.

## Finding 1 — one of the fork's two horns is ALREADY DISCHARGED

The issue quotes the `AccessCmd::Allow` docstring as saying the verb "creates the
restriction if absent", and asks whether the precise arm violates that reading.
**That sentence does not exist at `b88fab2a`.** `f2d215a5` replaced it with a
statement that names the divergence per spelling, in the docstring's own voice:

> THE MODE EFFECT DIFFERS BY SPELLING, so it is stated per spelling below rather
> than in one sentence that can only be half true: the positional spelling also
> flips the endpoint to restricted if no mode was set (the v1 verb's meaning, and
> it says `endpoint is now restricted` when it does), while the flag spelling
> writes the rule and leaves the modes alone.

The only surviving occurrences of the old phrasing are `restrict_if_unset`'s own
doc-comment (correctly scoped there as "the v1 semantic ... on its own") and one
unit-test name. So the docstring half of the fork needs no work; what is still
live is the SEMANTIC question and the inert-rule diagnostic the issue asks for in
its last clause.

## Finding 2 — where a posture-close could even GO, measured

`tuple_mutation` (cli.rs:15013) is the precise arm's body, and it is SHARED:

- `spt endpoint access allow|deny|remove` — the precise/flag spellings
- `spt api access-*` / `DaemonAccessCmd` (cli.rs:18305), the engine room's
  node-wide seat, including its per-endpoint `--for <id>` form
  (`via_daemon_seat = true`)

and below it, `AccessStore::apply_mutation` is the seam for:

- the knock-answer path (spt-daemon/answerop.rs:113)
- redeem (spt-daemon/redeemop.rs:143, :227) and knock.rs (:2015, :2068)

Consequences that decide the shape rather than the preference:

- a close inside `apply_mutation` would fire on **every approved knock and every
  redeem** — an endpoint's posture closed by a grant nobody typed as a posture.
- a close inside `tuple_mutation` would fire on the **engine room's `--for`
  seat** (the ER closing another endpoint's posture as a side effect of writing
  one rule for it), and is meaningless for `MutationScope::Node`
  (`restrict_if_unset` is per-endpoint by construction).
- therefore horn A is implementable ONLY in the CLI arm
  `AccessCmd::Allow { node: None, .. }` — which is exactly where the positional
  arm's close already sits (cli.rs:18664). **The posture close is a CLI-arm
  opinion either way; it is not, and cannot be, a property of the seam.**

## Finding 3 — "INERT" is conditional, and the precise statement matters

The chain (`AccessStore::decide`, access.rs:1536): tiers 1-3 endpoint rules →
4-5 node-scope rules → 6 endpoint mode → 7 node mode → 8 captured subnet mode →
9 implicit-open bottom.

An endpoint-scope ALLOW is inert **iff nothing in tiers 4-9 would have refused
that subject on those surfaces**. It is NOT inert when it shadows a node-scope
deny row, a closed node mode, or a captured closed subnet mode — that hole-punch
is the reason the precise arm exists, and the issue's flat "the written whitelist
rule is INERT" is true only of the unrestricted-everything case it was measured
in. The accurate framing for a ruling:

> the precise arm is a RULE verb; the positional arm is a RULE+POSTURE verb.

## Finding 4 — the codebase ALREADY RULES that an allow on an open posture is inert

`may_grant_node_subject` (access.rs:1679) derives the unset grant-nodes policy
from `effective_posture`, and its doc states the reasoning as law:

> **open ⇒ permitted** (an allow entry on an open surface punches no hole —
> everything already reaches it), **closed ⇒ engine-room only** (a node-subject
> allow would punch a hole in a deliberately closed posture).

This is binding gate code, not commentary. **Horn A would falsify that premise
inside the very command the policy gates**: the `--admit-node` acknowledgment is
relaxed BECAUSE the posture is open, and one statement later the command would
close the posture. Any horn-A ruling therefore also owes a re-ruling of the
grant-nodes policy — it is not a one-line addition.

## Finding 5 — a LOUDER second face of the same posture-blindness: a FALSE `ACCESS_LOCKED`

NOT RUNTIME-MEASURED. Source census only, offered as a defect candidate.

`EndpointAcl::is_locked()` (access.rs:830) is defined purely as "this record
ALLOWS no node subject" — `allowed_nodes().is_empty()`. **It never reads
`modes`.** Two consumers: cli.rs:18701 (the `revoke` arm's `ACCESS_LOCKED` line)
and cli.rs:18819 (the JSON view's `locked:` field).

A sequence reachable with today's verbs, no editing of the store by hand:

    spt endpoint access allow EP --node <hex> --admit-node   # precise: rule lands, modes stay EMPTY
    spt endpoint access revoke EP <hex>                      # row removed; modes STILL empty

`is_locked()` is now true, and the revoke arm prints

    ACCESS_LOCKED:EP now refuses ALL unsolicited remote inbound; ...

while the endpoint sits at the tier-9 implicit-open bottom and refuses nobody.
That is a confident wrong sentence to an operator about their own security
posture — the exact class `discover_still_open_line`'s own doc refuses ("a
correction that is itself wrong is worse than the silence it replaced"). The same
misnomer ships in the JSON view's `locked` field.

It is a consequence of the fork but not part of it, and it wants its own ticket:
under horn B it must be fixed outright; under horn A it heals for future writes
only, and never for stores already on disk.

## Finding 6 — the precise arm's posture behavior is UNPINNED

`crates/spt/tests/access_positional_allow_e2e.rs` pins the POSITIONAL close
(`narrow_posture == Some(Mode::Closed)`, "the non-regression that says the close
survived the move"). No test at `b88fab2a` pins the precise arm's posture in
either direction. **Whichever horn is ruled, a pin is owed** — today the ruling
would be unfalsifiable by the suite.

## Finding 7 — the state IS visible, just never at WRITE TIME

`cmd_access_view` (cli.rs:18776) renders each ruled endpoint's posture with the
node/subnet fallback word, and an unruled target prints "(default-open; posture
comes from the node and subnet tiers — see `spt node access`)". So an operator
CAN see that their precise grant left the endpoint open — by going to look.

The gap is write-time silence, and `tuple_mutation` already carries two
after-the-acceptance notices built for exactly this class: the ER-discover
conjunction notice and the fork-without-discover consequence, both under the
releases#163 doctrine "AFTER the acceptance, never instead of it". A third
notice fits the established shape rather than inventing one.

## CANDIDATE RULING (mine; doyle/operator decide)

**HORN B, with the diagnostic the issue's last clause already asks for:**
default-open-until-explicit-close IS the intended T6 semantic, and the precise
seam stays posture-blind.

1. **No posture change.** Findings 2 and 4: horn A is a CLI-arm-only opinion that
   either leaks into knock/redeem/the ER `--for` seat or duplicates a body that
   exists once, and it falsifies the grant-nodes policy's own stated premise.
2. **The docstring half is already discharged** (finding 1) — recommend saying so
   explicitly on the board so it is not re-opened from the issue text.
3. **NEW: an inert-grant notice on the precise CLI arm**, computed by ASKING THE
   CHAIN and never by re-deriving a verdict from rule rows (the
   `node_still_sees_endpoint` / `fork_without_discover` doctrine): when
   `op != Remove` and `decision == Allow` and, for every surface the rule covers,
   the chain ALREADY allowed that subject before the write, print — after the
   acceptance line — that the rule changes no verdict today, and name the lever
   that makes it bite. **Never a refusal**: pre-positioning a rule before a later
   close is legitimate and common.
4. **Pin it** whichever way it rules (finding 6).
5. **File finding 5 separately** as a BUGFIX candidate.

## Sub-questions I deliberately did NOT decide

a. `--any-of <subnet>` and `--endpoint <id>` subjects have no concrete
   `origin_node` to ask `decide()` with. `node_sees_endpoint_with` handles the
   subnet case by looping roster members — so a subnet with NO members would read
   "inert" vacuously, which is an absence rendered as a verdict. Loop members and
   say so when there are none, or restrict the notice to node subjects?
b. Does the notice belong on `deny` at all? A deny is not inert in the same way
   (it refuses a subject the bottom would have allowed), so I would say no — but
   the `--surfaces`-narrowed deny has a shape I have not censused.
c. No REQ owns this. None of the 24 `REQ-ACL-*` ids at `b88fab2a` covers
   write-time verdict-neutrality; a new one would be owed before any evidence is
   tagged.

## What is owed

1. Report to doyle — report-before-build, per his standing instruction.
2. Board comment on releases#196, drafted from this file, once doyle has read it.
3. NOTHING built, nothing branched, no pool claimed. This lane has no worktree.
