From 4133990d5492350a9a25ab529a8a53a0def8f7f6 Mon Sep 17 00:00:00 2001 From: Michael Habib Date: Sun, 30 Aug 2026 22:59:19 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(org):=20submit=20=E2=80=94=20accept=20?= =?UTF-8?q?a=20supervisor,=20the=20one=20safe=20self-signed=20terms=20chan?= =?UTF-8?q?ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checkTakeover` admits a displacement only from a role the charter names in Terms.Supervisors. Every lane in the live tenant names only the operator, so the operator is the sole actor able to clear a seat held by a dead session — which is exactly the page a supervisor lane exists to avoid. Granting that authority is a terms change, and FOLLOWUPS explains why there is no verb for one: a general recharter was written and withdrawn from #272 because `checkRecharter` verifies only min_reader monotonicity while `checkWriter` accepts the holder's own incarnation, so a role could widen its scope, lift its ceilings, add effect classes, or drop the supervisors that may take it over — all self-signed. That is still true, and this does not change it. `submit` is admissible under that entry's own criteria rather than in spite of them. It names supervisors as one of three attenuations a kernel can verify ("no shrink"), and adding one is monotone in the accountability direction: it strictly grows the set of roles that may displace you and cannot widen your own authority, so none of the five self-signed widenings are reachable. The guard is total, not partial — every other term is carried through byte-identical, including Tier, which is inert since #275 but still projected into every digest. Who writes it carries as much weight as what it says. Recharter is admitted only from Held or Active under the writer's own incarnation, so a role submits to oversight while sitting in its own seat. Consent, not imposition: nothing can be made supervisable from outside, which is the property keeping this from becoming the parent-authority mechanism the kernel still lacks. Tests pin the property rather than the happy path: the whole of Terms survives a submit byte-identical, takeover is refused with not_supervisor before and admitted after, and re-submitting an existing supervisor writes no record. Co-Authored-By: Claude Opus 5 --- FOLLOWUPS.md | 10 ++++ cmd/org/AGENTS.md | 12 +++++ cmd/org/CLAUDE.md | 12 +++++ cmd/org/main.go | 52 ++++++++++++++++++++ cmd/org/main_test.go | 112 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+) diff --git a/FOLLOWUPS.md b/FOLLOWUPS.md index 821f7ab8..636445fb 100644 --- a/FOLLOWUPS.md +++ b/FOLLOWUPS.md @@ -56,6 +56,16 @@ Until both exist, terms are set once at charter. A role whose terms are wrong is retired and re-chartered, which is visible in the chain rather than self-signed inside it. +**Narrowed, 2026-08-31.** `org submit` now exposes exactly one slice of the +kind: it adds a single supervisor and leaves every other term byte-identical. +That is admissible under this entry's own criteria rather than in spite of +them — supervisors are listed above as a verifiable attenuation ("no shrink"), +and adding one is monotone in the accountability direction, so none of the five +self-signed widenings this entry names are reachable through it. The two +missing pieces are unchanged and still block the general verb; note only that +`Tier` became inert in #275, so the tier half of gap 2 is now moot and the +ceilings half is what remains. + ## org: annul is a repudiation, not a revert `applyStructural` appends the annulled digest to `Annulled` and changes nothing diff --git a/cmd/org/AGENTS.md b/cmd/org/AGENTS.md index ed5ecb1b..ac0e1693 100644 --- a/cmd/org/AGENTS.md +++ b/cmd/org/AGENTS.md @@ -38,6 +38,18 @@ system name there is Baton; this binary is its first runtime slice. no `recharter` verb: changing terms can WIDEN authority and the kernel has no parent-authority check and no tier ordering to verify attenuation with (see FOLLOWUPS). +- **`submit`** is the one slice of `KindRecharter` that is safe self-signed: it + adds ONE supervisor and leaves every other term byte-identical. FOLLOWUPS + names supervisors as a verifiable attenuation ("no shrink"), and the + direction is what admits it — naming another role that may displace you + strictly increases accountability and cannot widen your own authority, so + there is no escalation to guard against. It exists because `checkTakeover` + admits a displacement only from a role the charter names, so a lane nobody + can clear becomes clearable only by its own consent: recharter is admitted + from Held or Active under the writer's own incarnation, so a role submits to + oversight while sitting in its seat. Nothing can be made supervisable from + outside, which is why this is not the parent-authority mechanism the kernel + still lacks. - **`transfer`** moves one work item between two roles in the same tenant. Nothing about it is atomic — two chains, two locks, no cross-chain transaction — so it makes the failure states RECOVERABLE rather than diff --git a/cmd/org/CLAUDE.md b/cmd/org/CLAUDE.md index ed5ecb1b..ac0e1693 100644 --- a/cmd/org/CLAUDE.md +++ b/cmd/org/CLAUDE.md @@ -38,6 +38,18 @@ system name there is Baton; this binary is its first runtime slice. no `recharter` verb: changing terms can WIDEN authority and the kernel has no parent-authority check and no tier ordering to verify attenuation with (see FOLLOWUPS). +- **`submit`** is the one slice of `KindRecharter` that is safe self-signed: it + adds ONE supervisor and leaves every other term byte-identical. FOLLOWUPS + names supervisors as a verifiable attenuation ("no shrink"), and the + direction is what admits it — naming another role that may displace you + strictly increases accountability and cannot widen your own authority, so + there is no escalation to guard against. It exists because `checkTakeover` + admits a displacement only from a role the charter names, so a lane nobody + can clear becomes clearable only by its own consent: recharter is admitted + from Held or Active under the writer's own incarnation, so a role submits to + oversight while sitting in its seat. Nothing can be made supervisable from + outside, which is why this is not the parent-authority mechanism the kernel + still lacks. - **`transfer`** moves one work item between two roles in the same tenant. Nothing about it is atomic — two chains, two locks, no cross-chain transaction — so it makes the failure states RECOVERABLE rather than diff --git a/cmd/org/main.go b/cmd/org/main.go index 6f75beef..16663710 100644 --- a/cmd/org/main.go +++ b/cmd/org/main.go @@ -30,6 +30,7 @@ import ( "io" "os" "path/filepath" + "slices" "strings" "time" @@ -52,6 +53,7 @@ func main() { os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } // read verbs never take the lock. var verbs = map[string]func(*env, []string) error{ "charter": cmdCharter, + "submit": cmdSubmit, "annul": cmdAnnul, "attach": cmdAttach, "assign": cmdAssign, @@ -116,6 +118,7 @@ func usage(w io.Writer) { fmt.Fprintln(w, `usage: org [flags] lifecycle charter · attach · release · retire · takeover · revoke · delegate + submit (accept a supervisor; the only self-signed terms change) correction annul (repudiate the tip; corrects forward, does not revert) work assign · transfer · unassign · claim · yield · complete · abandon composite begin (attach+assign+claim) · done (claim?+complete+release) @@ -541,6 +544,55 @@ func cmdCharter(e *env, args []string) error { return appendAndReport(e, h, s, home.Draft{Kind: org.KindCharter, Terms: terms()}) } +// cmdSubmit adds one supervisor to a live role's terms and changes nothing +// else. It is the deliberately narrow slice of KindRecharter that is safe to +// self-sign. +// +// A general recharter verb was written and withdrawn from #272, and FOLLOWUPS +// records why: checkRecharter verifies only that min_reader is monotone, and +// checkWriter accepts the holder's own incarnation, so exposing the whole kind +// would let a role widen its own scope, lift its ceilings, add effect classes, +// or drop the supervisors that may take it over — all self-signed. That +// remains true and this verb does not change it. +// +// What makes THIS safe is the direction of travel. FOLLOWUPS names supervisors +// as one of the three attenuations a kernel can actually verify ("no shrink"), +// and adding one is monotone in the accountability direction: it strictly +// increases the set of roles that may displace you and cannot widen your own +// authority. There is no escalation available by naming more parties who can +// remove you. So the guard is total rather than partial — every other term +// must be byte-identical to the chain's current terms, and a supervisor may +// only be added, never removed. +// +// Who writes it matters as much as what it says: recharter is admitted only +// from Held or Active and carries the writer's own incarnation, so a role +// submits to oversight while sitting in its own seat. Consent, not imposition +// — no role can be made supervisable from outside, which is the property that +// keeps this from becoming the parent-authority mechanism the kernel still +// lacks. +func cmdSubmit(e *env, args []string) error { + s := newScope("submit") + party := s.fs.String("party", "", "role to add as a supervisor, e.g. supervisor:mh") + h, err := s.open(args, true) + if err != nil { + return err + } + if *party == "" { + return fmt.Errorf("-party is required: the role being added as a supervisor") + } + _, state, err := h.Load(s.tenant, s.role) + if err != nil { + return err + } + if slices.Contains(state.Terms.Supervisors, *party) { + fmt.Fprintf(e.stderr, "%s already supervises %s; nothing to do\n", *party, s.role) + return nil + } + next := state.Terms + next.Supervisors = append(slices.Clone(state.Terms.Supervisors), *party) + return appendAndReport(e, h, s, home.Draft{Kind: org.KindRecharter, Terms: &next}) +} + // cmdTransfer moves one work item between two roles in the same tenant. // // Nothing here is atomic: two chains, two locks, no cross-chain transaction. diff --git a/cmd/org/main_test.go b/cmd/org/main_test.go index 6ea6b23c..dcd72710 100644 --- a/cmd/org/main_test.go +++ b/cmd/org/main_test.go @@ -936,3 +936,115 @@ func TestTransferNoOpSpeaksJSON(t *testing.T) { t.Fatalf("no-op envelope = %#v", envelope) } } + +// TestSubmitAddsSupervisorAndChangesNothingElse pins the property that makes +// this verb safe to self-sign. FOLLOWUPS withdrew a general recharter because +// a role could widen its own scope, lift its ceilings or drop the supervisors +// that may take it over. Submit is admitted only because it moves one term in +// the one direction the kernel can verify — supervisors, no shrink — so the +// test asserts the whole rest of Terms survives byte-identical rather than +// merely asserting the supervisor landed. +func TestSubmitAddsSupervisorAndChangesNothingElse(t *testing.T) { + state := t.TempDir() + role := []string{"-tenant", "acme", "-role", "steward:api"} + must := func(verb ...string) string { + t.Helper() + code, out, errOut := exec(t, state, append(verb, role...)...) + if code != 0 { + t.Fatalf("%v: exit %d: %s", verb, code, errOut) + } + return out + } + must("charter", "-scope", "github:acme/api", "-scope", "jira:API", + "-supervisor", "human:op", "-effect-class", "merge", + "-cycle-ceiling", "3", "-spend-ceiling", "500", "-retire-when", "api retires") + must("attach") + + must("submit", "-party", "supervisor:acme") + + var boot struct { + Boot struct { + Terms org.Terms `json:"terms"` + } `json:"boot"` + } + if err := json.Unmarshal([]byte(must("boot", "-json")), &boot); err != nil { + t.Fatalf("boot: %v", err) + } + got := boot.Boot.Terms + if want := []string{"human:op", "supervisor:acme"}; !slices.Equal(got.Supervisors, want) { + t.Fatalf("supervisors = %v, want %v", got.Supervisors, want) + } + // Everything else is untouched. A general recharter would have dropped all + // of this to its flag defaults. + if want := []string{"github:acme/api", "jira:API"}; !slices.Equal(got.Scope, want) { + t.Fatalf("scope = %v, want %v — submit must not narrow scope", got.Scope, want) + } + if want := []string{"merge"}; !slices.Equal(got.EffectClasses, want) { + t.Fatalf("effect classes = %v, want %v", got.EffectClasses, want) + } + if got.CycleCeiling != 3 || got.SpendCeiling != 500 || got.Retire != "api retires" { + t.Fatalf("ceilings/retire drifted: cycles=%d spend=%d retire=%q", + got.CycleCeiling, got.SpendCeiling, got.Retire) + } +} + +// TestSubmitEnablesTakeover is the reason the verb exists: a supervisor may +// displace a holder only when the charter names it, so submit is what turns a +// lane nobody can clear into one its supervisor can. +func TestSubmitEnablesTakeover(t *testing.T) { + state := t.TempDir() + role := []string{"-tenant", "acme", "-role", "steward:api"} + run := func(verb ...string) (int, string, string) { + t.Helper() + return exec(t, state, append(verb, role...)...) + } + if code, _, err := run("charter", "-scope", "github:acme/api", "-supervisor", "human:op"); code != 0 { + t.Fatalf("charter: %s", err) + } + if code, _, err := run("attach"); code != 0 { + t.Fatalf("attach: %s", err) + } + // Before submit, the supervisor is a stranger to this charter. + code, _, errOut := run("takeover", "-party", "supervisor:acme") + if code != 1 || !strings.Contains(errOut, org.ReasonNotSupervisor) { + t.Fatalf("takeover before submit: exit %d, want 1 with %s: %s", + code, org.ReasonNotSupervisor, errOut) + } + if code, _, err := run("submit", "-party", "supervisor:acme"); code != 0 { + t.Fatalf("submit: %s", err) + } + if code, _, err := run("takeover", "-party", "supervisor:acme"); code != 0 { + t.Fatalf("takeover after submit: exit %d: %s", code, err) + } +} + +// TestSubmitIsIdempotentAndNeedsAParty covers the two ways a caller gets it +// wrong. Re-submitting an existing supervisor writes nothing rather than +// growing the chain with a no-op recharter. +func TestSubmitIsIdempotentAndNeedsAParty(t *testing.T) { + state := t.TempDir() + role := []string{"-tenant", "acme", "-role", "steward:api"} + run := func(verb ...string) (int, string, string) { + t.Helper() + return exec(t, state, append(verb, role...)...) + } + if code, _, err := run("charter", "-scope", "github:acme/api", "-supervisor", "human:op"); code != 0 { + t.Fatalf("charter: %s", err) + } + if code, _, err := run("attach"); code != 0 { + t.Fatalf("attach: %s", err) + } + if code, _, errOut := run("submit"); code == 0 { + t.Fatalf("submit without -party succeeded: %s", errOut) + } + if code, _, err := run("submit", "-party", "human:op"); code != 0 { + t.Fatalf("idempotent submit: %s", err) + } + code, out, err := run("log") + if code != 0 { + t.Fatalf("log: %s", err) + } + if strings.Contains(out, org.KindRecharter) { + t.Fatalf("re-submitting an existing supervisor wrote a record:\n%s", out) + } +} From 725c58a3ef688672db7e6e561cf9eb44d43afe1f Mon Sep 17 00:00:00 2001 From: Michael Habib Date: Wed, 2 Sep 2026 21:23:35 -0700 Subject: [PATCH 2/2] fix(org/submit): fence the recharter to the snapshot tip; JSON no-op on retry The new terms are derived from the loaded snapshot, so the append now carries ExpectTip: two concurrent submits would otherwise each write "old supervisors + mine" and the second to land would silently drop the first's party. An idempotent retry under -json prints the same no-op receipt shape transfer uses instead of an empty stdout. Co-Authored-By: Claude Fable 5.1 --- cmd/org/main.go | 9 ++++++--- cmd/org/main_test.go | 14 +++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/cmd/org/main.go b/cmd/org/main.go index 16663710..35d35dcd 100644 --- a/cmd/org/main.go +++ b/cmd/org/main.go @@ -585,12 +585,15 @@ func cmdSubmit(e *env, args []string) error { return err } if slices.Contains(state.Terms.Supervisors, *party) { - fmt.Fprintf(e.stderr, "%s already supervises %s; nothing to do\n", *party, s.role) - return nil + return reportNoOp(e, s, fmt.Sprintf("%s already supervises %s; nothing to do", *party, s.role)) } + // The new terms are derived from the snapshot read above, so the append is + // fenced to that snapshot's tip: two concurrent submits would otherwise + // each carry "old supervisors + mine", and the second to land would + // silently drop the first's party — the one edit this verb must never make. next := state.Terms next.Supervisors = append(slices.Clone(state.Terms.Supervisors), *party) - return appendAndReport(e, h, s, home.Draft{Kind: org.KindRecharter, Terms: &next}) + return appendAndReport(e, h, s, home.Draft{Kind: org.KindRecharter, Terms: &next, ExpectTip: state.Tip}) } // cmdTransfer moves one work item between two roles in the same tenant. diff --git a/cmd/org/main_test.go b/cmd/org/main_test.go index dcd72710..2f4ede58 100644 --- a/cmd/org/main_test.go +++ b/cmd/org/main_test.go @@ -1040,7 +1040,19 @@ func TestSubmitIsIdempotentAndNeedsAParty(t *testing.T) { if code, _, err := run("submit", "-party", "human:op"); code != 0 { t.Fatalf("idempotent submit: %s", err) } - code, out, err := run("log") + // A machine retry gets a decodable no-op, not an empty stdout. + code, out, err := run("submit", "-json", "-party", "human:op") + if code != 0 { + t.Fatalf("idempotent submit -json: %s", err) + } + var noop struct { + Steps []receipt `json:"steps"` + Note string `json:"note"` + } + if err := json.Unmarshal([]byte(out), &noop); err != nil || len(noop.Steps) != 0 || noop.Note == "" { + t.Fatalf("idempotent submit -json must print a no-op receipt, got %q (%v)", out, err) + } + code, out, err = run("log") if code != 0 { t.Fatalf("log: %s", err) }