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..35d35dcd 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,58 @@ 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) { + 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, ExpectTip: state.Tip}) +} + // 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..2f4ede58 100644 --- a/cmd/org/main_test.go +++ b/cmd/org/main_test.go @@ -936,3 +936,127 @@ 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) + } + // 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) + } + if strings.Contains(out, org.KindRecharter) { + t.Fatalf("re-submitting an existing supervisor wrote a record:\n%s", out) + } +}