v3.5.1 - authorization the repo grants, not the user #188

Merged
john merged 36 commits from cadence/v3.5.1 into main 2026-08-16 02:08:43 +00:00
Owner

Closes #131, #179, #180.

  • git.auto_close resolves as two booleans, autoCloseRequested from the
    merged config and autoCloseAuthorized from the repository layer alone,
    so a user-global true stops speaking for a repository that never opted
    in. The GitLab arm consults it ahead of its reuse probe rather than
    beside the create, which is the path that was ungated (AUT-01, AUT-02).

  • milestone-prune reads a whole wrapped bullet span through a fence-aware

    Active bound and escapes pipes at the table cell. Proved on this

    repo's own close: all four archived rows carry five unescaped pipes and

    Active kept no orphaned fragments (PRN-01).

  • The tracker report binds by tea --remote origin, guarded by a login
    naming the origin host exactly. The login-matching surface FAILed its
    blocking gate twice and was deleted rather than patched a third time
    (TRK-01).

Full notes in CHANGELOG.md. The v3.5.1 tag is cut after this merges, on
the pulled base.

Closes #131, #179, #180. - git.auto_close resolves as two booleans, autoCloseRequested from the merged config and autoCloseAuthorized from the repository layer alone, so a user-global true stops speaking for a repository that never opted in. The GitLab arm consults it ahead of its reuse probe rather than beside the create, which is the path that was ungated (AUT-01, AUT-02). - milestone-prune reads a whole wrapped bullet span through a fence-aware ## Active bound and escapes pipes at the table cell. Proved on this repo's own close: all four archived rows carry five unescaped pipes and ## Active kept no orphaned fragments (PRN-01). - The tracker report binds by tea --remote origin, guarded by a login naming the origin host exactly. The login-matching surface FAILed its blocking gate twice and was deleted rather than patched a third time (TRK-01). Full notes in CHANGELOG.md. The v3.5.1 tag is cut after this merges, on the pulled base.
john added 36 commits 2026-08-16 02:08:16 +00:00
Under user-global git.auto_close true with the repository silent, /cad-land
step 3(b)'s GitLab arm skips the publish ask, passes the land-cleanup gate,
and reaches `glab mr merge` having consulted no authorization at all. The
spike records the run rather than the claim: the three seam envelopes
verbatim, and a $CAD_SPAWN_MARKER file showing `glab mr create` then
`glab mr merge` with nothing between them.

Also pins what the fix must and must not move: the gate behaved correctly on
both inputs and is not a fix site, and today's `auto-close-off` envelope
cannot say which authorization was missing.
repoAutoClose leaves git-publish.mjs for lib/repo-auto-close.mjs with its
behaviour byte-for-byte: the raw JSON.parse of <dir>/.planning/config.json,
true only on an explicit git.auto_close === true, false on every throw.

It stays a raw read rather than mergeLayers(...).layers.repo because a
merge-derived answer would let a torn user-global file withdraw a
repository's authorization, and an authorization check must never fail in
that direction. The module header carries that reason, and the two
resolutions the key now has, beside the what.

Six arms cover repo true, repo false, key absent, config absent, truncated
JSON, and a user-global true against a silent repository.
decidePublish's gate 1 refused from autoClose !== true alone, so its
envelope could not tell "off everywhere" from "on in the user's home
directory, and this repository never opted in" - the second being the state
a user has to act on.

authorizationDetail is one pure, total function taking the requested
(merged) and authorized (repo-layer) values and returning the sentence that
names which authorization was missing, or null when the repository did
authorize. Gate 1 carries it as detail; reason stays the token
auto-close-off on both arms, and it is the only refusal that carries a
detail, so every other arm's envelope is unchanged.
publish now names its two inputs at the boundary: the authorized value from
the repository layer alone, and the requested (merged) value read off the
mergeLayers call readProtectedBranches already makes, so no second callsite
appears. Only the first unlocks the push; the second reaches the envelope
as the detail sentence and nothing else.

authorized [--dir <path>] answers the same question and mutates nothing -
no git, no subprocess at all. It exists for the hosts whose publishing CLI
pushes the branch itself, where no seam call happens today and an
unattended merge proceeds on a value the repository never set. It carries
no torn-layer refusal on purpose: refusing there would let one corrupt file
in a home directory withdraw a repository's authorization.
issue-check.test.mjs exports its stub writer and binds test to a no-op
unless it is the entry file, so the harness that already owns the
$CAD_SPAWN_MARKER convention can be imported rather than copied; its 18
arms still register exactly once.

The new git-publish arm puts gh, glab and tea stubs on the child's PATH
with the marker set, runs authorized against a global-true/repo-unset
fixture, and asserts both the refusal and that the marker file was never
written. glab is not installed on this machine, so the stub is the only way
the arm is provable either way, and running one stub afterwards shows the
marker is not vacuously absent.
Both surfaces stated the gap as correct: "On GitLab glab mr create
publishes the source branch itself, so no seam call is needed there". It is
the opposite - the create IS the mutation, and on that host nothing stood
between a user-global git.auto_close and an unattended merge.

Step 3(b) now runs git-publish.mjs authorized before the create and creates
nothing on ok:false, one consult rather than a second check beside the
merge, and references/git-publish.md rail 3 states the same rule.

The prose-agreement pin asserts ORDER inside the 3(b) region, not presence:
a tree that keeps the seam call but moves it after the create has already
published the branch by the time it asks. Watched red on both mutations -
the old sentence restored, and the call moved after the create. Both
budgets re-pinned in the same commit.
The schema purpose and the config-reach row both described a one-consumer
world: the repo layer read by git-publish publish, and the merged value read
by everything else. Neither said that a global true skips the ask and still
refuses, which is the state a user actually lands in.

Both now name the two resolutions and which arm reads which, including the
authorized arm and the GitLab consult. The reach phrase stays verbatim in
the purpose - check 9 compares it as a literal substring, watched to report
unstated-reach for git.auto_close with the phrase paraphrased away. No new
key; the reach cell itself is unchanged because it is still true.
config-reach.md re-pinned at 17436B.
The cross-seam divergence arm now asserts all four facts on ONE config
pair: config.mjs get reports true, git-publish.mjs authorized refuses from
the repo layer alone, the two therefore differ, and land-cleanup.mjs gate
still halts on a surviving blocker. Its comment names the two resolutions
and why re-aligning them re-runs 0b1c322. Watched red with the repo read
re-pointed at the merged value.

Those facts are all seam-level, so they hold on a tree where SKILL.md was
repointed at the raw repo value - ask and gate reading different sources,
every arm green. The call-site pin closes that: step 3's branch value comes
from the up-front config.mjs get and from no other source, with authorized
allowed only inside 3(b) where it gates the GitLab mutation. Watched red
with step 3 branching on a direct .planning/config.json read.

Whole suite 1932 pass, self-verify problems [].
The risk_surface review found the consult placed beside `glab mr create`
leaves the reuse arm ungated: an MR already open for the branch skips the
create entirely, so `glab mr merge` lands with no seam call behind it and the
repository never authorized the merge. Hoist the one consult ahead of the whole
bullet - before the reuse probe - and pin the order against the probe, not only
against the create. Budget row re-pinned for the added prose.
Step 1's detected-host sentence and step 3a's Open MR/PR bullet both said a
`tea` login matches by naming the origin host, which is what cost this
repository its tracker report and its publish option; both now say a login
matches when it names the host or shares its registrable domain. Step 1's
`report` branch says how to render a number the seam could not resolve.

The `skills/cad-land/SKILL.md` budget row is re-pinned 13145 -> 13179. The
corrected sentences do not fit under the old entry: the shared-domain rule is
stated twice by requirement and the unresolved rendering rides the same
sentence, and the wording was already tightened (the `tea login list`
parenthetical and "none of those" both came out) before re-pinning.
The shared-registrable-domain test proved only that SOME configured login
could serve the origin; the call it authorized still went out with an
unqualified `--repo <owner>/<name>`, which tea resolves in config FILE
ORDER. With an unrelated login first and the matching one second, the
predicate passed on the second while the query answered from the first -
another server's issues reported as this repository's, exit 0 and all.

classifyOrigin now takes login RECORDS (the `--login` name plus the hosts
that identify it) instead of a flattened host list, names the login its
match was made on, and drops any login the reading could not name, because
a login whose call cannot be bound may not decide the verdict. The forgejo
row spends that name as `--login` on the list call and on every per-issue
resolve. Where more than one login matches, the pick is deterministic:
exact host before shared domain, first in the reading order within each.

github and gitlab are untouched - neither has tea's config-order fallback,
and glab is absent from this machine.
PUBLIC_TWO_LABEL stopped at nine entries, so `registrableDomain` read
`co.za` as a registrable domain: origin `git.acme.co.za` and login
`git.other.co.za` answered the same domain and classifyOrigin called two
unrelated companies one forge. The same held for `com.br`'s neighbours,
`co.kr`, `com.tr` and the rest of the world's second-level registries.

The set now lists the ccTLD second-level registries a git host plausibly
sits on, plus the hosting suffixes in the same class as `github.io`. It
stays a frozen curated Set - no vendored PSL, the zero-dep constraint holds
- and a denied pair still falls back to the `no-login` skip. A miss here is
the failure; a listed suffix no registry operates costs only a skip.
Three rounds of guessing come out in one cut: `registrableDomain`, the
`PUBLIC_TWO_LABEL` denylist, the exact-name-before-shared-domain precedence
rule, and the matched login's name threaded onto the classification and spent
as `--login` on both forgejo argv. Which login serves this origin is tea's
question, and the next commit hands it back with `--remote origin`.

classifyOrigin now asks the `tea login list` reading for its LENGTH: at least
one login is `forgejo`, none is `no-login`, no reading at all is
`unrecognized`. The five verdicts and their reason lines are unchanged.
issue-check.mjs's login-record plumbing (name + api-url + ssh_host extraction)
went with the rule it existed for; the probe reading is parsed as a list and
nothing more.

The suite is red here on purpose - 7 arms in issue-decision.test.mjs and 3 in
issue-check.test.mjs pin the deleted rule, and they are rewritten two commits
on. `npx tsc -p tsconfig.ci.json` is clean.
Both forgejo argv - the open list and the per-issue resolve - carry
`--remote origin`, so tea discovers the login from the checkout's own remote
instead of resolving an unqualified `--repo owner/name` in config file order
(D-07). The seam already spawns with cwd set to the repository --dir names,
which is what makes `origin` resolvable. `github` and `gitlab` are byte-identical.

Measured on tea 0.15.1, 2026-08-15: `--remote` and `--repo` compose (in a
codeberg.org checkout, `--repo forgejo/forgejo --remote origin` answered from
the codeberg login, which sits second in the config), and `--remote` exists on
the single-issue form as well as on `list`.

The limit of delegating it is stated in HOST_TABLE's header rather than guarded:
when no login's host names the remote's host, tea falls back to config order,
says so on STDERR and exits 0 - and this seam discards child stderr by contract,
so it cannot see that happen. The fix for a user in that position is a login
whose ssh_host names the remote's host. A host rule here was tried twice and
needs a public suffix list a zero-dep repo refuses to vendor.

Live: `issue-check.mjs check --dir /data/code/cadence --base main` returns
action report, host ssh.jcrenshaw.dev, repo crenshawdev/cadence, 26 open on a
complete read; `tea issues 179 --repo crenshawdev/cadence --remote origin
--fields index,state --output json` answers exit 0 with EMPTY stderr. The suite
is still red on the same 10 arms, rewritten next. tsc clean.
The 10 arms that pinned the deleted host rule are rewritten or gone:

- DELETED, they only ever pinned the inference: `classifyOrigin NAMES the
  matched login...` (the exact-before-shared precedence and the login name on
  the verdict) and `a shared PUBLIC two-label suffix is not a shared domain`
  (the denylist, all 17 pairs).
- REWRITTEN to the new rule: a login list naming ANY login makes a non-github
  host forgejo; an SSH endpoint no login names is forgejo and reports the
  ORIGIN's host; an EMPTY reading is now the only thing that answers no-login,
  and it still answers on the line it always printed.
- The argv assertions now read `--remote origin` on both forgejo forms, and no
  row's argv - list or resolve - may carry a `--login` at all.

Seam-side, from CAD_ARGV_LOG: every one of the three forgejo calls carries
`--remote origin`, none carries `--login`, and neither of the two configured
logins is named on any of them; the two resolve command lines are asserted
whole. Added `the github and gitlab argv are byte-identical to what they were`,
which asserts each of those two recorded command lines byte for byte, so a flag
leaking across rows is caught.

DEVIATION: `an origin sharing no registrable domain with any login skips, and
queries nothing` asserted the rule this cut deleted (PLAN AC4, CONTEXT D-07), so
it could not be kept. It is replaced by the two halves that are now true: an
EMPTY `tea login list` reading skips on the same line and makes no issue query
(marker file plus argv log), and an origin no login names is handed to tea bound
with `--remote origin`, naming no login the seam picked.

Whole suite 1948 pass / 0 fail, self-verify ok:true with no problems, tsc clean.
`stub`'s exported signature is untouched, so git-publish.test.mjs still binds.
Both places that stated a shared-registrable-domain match now state the rule
that shipped in its place: step 1's detected-host sentence reads "any other host
where `tea` holds a login", and step 3a's Open MR/PR bullet is absent only when
there is no remote or `tea` holds no login at all. The `unresolved` rendering
sentence is untouched.

Which login serves a given remote is deliberately not stated here: it is tea's
own answer, taken from the `origin` remote by `--remote origin`, and the limit
of delegating it is documented where the binding happens (lib/issue-decision.mjs,
HOST_TABLE) rather than restated as a skill rule.

The edit only removes bytes, so the weight budget is re-pinned honestly at the
new exact size (13179 -> 13106). DOCS-CLAIMS' two cad-land line citations shift
by the one line that came out (32-55 -> 32-54, 56-57 -> 55-56); no claim text,
verdict or row is added or changed.

self-verify ok:true with no problems, suite 1948/1948 including
prose-agreement.test.mjs, tsc clean.
The cut delegated the login binding to tea's --remote origin, but tea does
not refuse an unmatched remote - it falls back to config order, exits 0, and
prints its NOTE on the stderr this seam discards. Deleting the host-matching
rule also deleted the precondition that any login names this host, so a
remote no login serves reported instead of skipping, and the report could
carry another server's issues.

The guard is exact equality against the three fields a login identifies its
forge by, and it is a precondition on making the call rather than a rule for
picking a login: which login serves a matched remote is still tea's answer.
Equality asks what a login is NAMED, never what two hosts have in common,
which is why it needs no vendored public suffix list.

A split-endpoint remote is reportable when the login's ssh_host names the
endpoint, and the no-login line is what tells the user to add it.
docs: evolve to v3.5.2, one reader one transport
Some checks failed
test / node-test (git, 22) (pull_request) Successful in 46s
test / node-test (git, 24) (pull_request) Successful in 22s
test / node-test (other, 22) (pull_request) Failing after 19s
test / node-test (other, 24) (pull_request) Failing after 21s
test / node-test (planning, 22) (pull_request) Successful in 1m9s
test / node-test (planning, 24) (pull_request) Successful in 1m2s
test / node-test (prose, 22) (pull_request) Successful in 26s
test / node-test (prose, 24) (pull_request) Successful in 26s
test / node-test (review, 22) (pull_request) Successful in 15s
test / node-test (review, 24) (pull_request) Successful in 15s
test / node-test (routing, 22) (pull_request) Successful in 26s
test / node-test (routing, 24) (pull_request) Successful in 25s
test / self-verify (pull_request) Successful in 12s
test / typecheck (pull_request) Successful in 20s
87906d8cc5
john merged commit 8b2b81e2f1 into main 2026-08-16 02:08:43 +00:00
Sign in to join this conversation.
No description provided.