v3 workflow: intent, phases, review, submission, enforcement, burn-in
The v3 stack, previously an uncommitted working tree, plus this session's two units and the burn-in instrument. This commit is the burn-in build identity: coordinator and worker must both report this revision before a task is created. Workflow (earlier sessions, uncommitted until now): human decision events and reduction, source cursors and reconcile-before-launch, turn-boundary reconciliation, internal/agentctx as the single renderer, ace-fca phases with sealed artifacts, the trajectory gate, bounded grilling, independent review, task pr enforcement, and human review reflection. Capability restrictions at the agent boundary: an authz.Agent surface at GatedWrite may ask and may not act. It also fixes two bugs the unit exposed -- gated surfaces could not reach the two endpoints written for them, and RequestHumanDecision would block an unowned task while rejecting a question from the session that did own it. Turn-boundary reconcile-failure escalation: a streak of consecutive failures asks the session to hand off, fenced on the lease epoch, with reconcile_failure as a real handoff reason. The worker was dropping the coordinator's verdict on the floor; it now acts on it. Burn-in: herdr.WriteLaunchContext dumps the exact agentctx.Build result to <worktree>/.orchestra/launch.md at every launch, local and federated. BURNIN.md is the runbook. deploy/build.sh stamps both binaries from one commit. go build, go vet and go test ./... pass, 20 packages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+27
-6
@@ -25,6 +25,11 @@ const (
|
||||
Web Surface = "web"
|
||||
MCP Surface = "mcp"
|
||||
Maven Surface = "maven"
|
||||
// Agent identifies a coding session running inside a harness pane. It may
|
||||
// perform work and *request* lifecycle changes; it may never perform one.
|
||||
// Everything Orchestra owns — phase, review, submission, completion, lease
|
||||
// state — is denied to it by GatedWrite, at the endpoint and at the bus.
|
||||
Agent Surface = "agent"
|
||||
// System identifies the plane itself — the router, coordinator, provider
|
||||
// adapters, and lease-expiry reclaim. Per invariant 2 ("the plane emits
|
||||
// events, not the agent"), these are the only non-surface emitters and are
|
||||
@@ -50,7 +55,7 @@ func CapabilityFor(s Surface) Capability {
|
||||
return NotifyOnly
|
||||
case TUI, Web, System:
|
||||
return FullControl
|
||||
case MCP, Maven:
|
||||
case MCP, Maven, Agent:
|
||||
return GatedWrite
|
||||
default:
|
||||
return Observe
|
||||
@@ -79,6 +84,22 @@ func AuthorizeEvent(s Surface, typ string) error {
|
||||
// credentials are exchanged once for this HttpOnly receipt.
|
||||
const SessionCookie = "orchestra_session"
|
||||
|
||||
// HarnessTurnPath authenticates its own bearer token inside the handler, the
|
||||
// way federation endpoints do. It needs an exemption from the surface gate
|
||||
// below for the same reason they do: an unlabelled request defaults to the Web
|
||||
// surface, which is session-gated, so a harness could never reach it.
|
||||
const HarnessTurnPath = "/v1/harness/turn"
|
||||
|
||||
// GatedWritePaths are the only mutating paths a GatedWrite surface may reach.
|
||||
// Each one records a request — an approval, a bounded question, a deferred
|
||||
// finding — and none of them moves the lifecycle. Handlers re-check with
|
||||
// AuthorizeEvent, so widening this list alone cannot grant authority.
|
||||
func GatedWritePath(p string) bool {
|
||||
return strings.HasSuffix(p, "/approval") ||
|
||||
strings.HasSuffix(p, "/decision-request") ||
|
||||
strings.HasSuffix(p, "/deferred")
|
||||
}
|
||||
|
||||
// SessionPath is the one Web-surface endpoint exempt from the session gate,
|
||||
// because it verifies login credentials and exchanges them for a cookie.
|
||||
const SessionPath = "/v1/ui/session"
|
||||
@@ -205,7 +226,7 @@ func HTTPWithSessions(tokens map[Surface]string, sessions *Sessions, next http.H
|
||||
(r.Method == http.MethodGet && r.URL.Path == "/v1/tasks") ||
|
||||
(r.Method == http.MethodPost && r.URL.Path == "/v1/artifacts") ||
|
||||
(r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/artifacts/"))
|
||||
if federationRegistration || (worker && workerPath) {
|
||||
if federationRegistration || r.URL.Path == HarnessTurnPath || (worker && workerPath) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -246,10 +267,10 @@ func HTTPWithSessions(tokens map[Surface]string, sessions *Sessions, next http.H
|
||||
http.Error(w, "notify-only surface", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if (s == MCP || s == Maven) && r.Method != http.MethodGet && r.Method != http.MethodHead && r.URL.Path != "/v1/events" {
|
||||
// Gated clients may submit only approval requests; ordinary control
|
||||
// endpoints must never become an accidental write path.
|
||||
if !strings.HasSuffix(r.URL.Path, "/approval") {
|
||||
if CapabilityFor(s) == GatedWrite && r.Method != http.MethodGet && r.Method != http.MethodHead && r.URL.Path != "/v1/events" {
|
||||
// Gated clients may only ask; ordinary control endpoints must never
|
||||
// become an accidental write path.
|
||||
if !GatedWritePath(r.URL.Path) {
|
||||
http.Error(w, "approval required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -186,3 +186,93 @@ func TestSessionRevoke(t *testing.T) {
|
||||
t.Fatal("revoked session must not be valid")
|
||||
}
|
||||
}
|
||||
|
||||
// The agent boundary: an agent may perform work and request lifecycle changes,
|
||||
// never perform one. Both halves are proven here — the bus refuses the event
|
||||
// types Orchestra owns, and the middleware refuses their endpoints — because
|
||||
// an agent that reaches a handler with a valid token would otherwise be
|
||||
// indistinguishable from the browser operator.
|
||||
func TestAgentSurfaceCannotMutateLifecycle(t *testing.T) {
|
||||
for _, typ := range []string{
|
||||
"TaskLeased", "TaskReleased", "TaskCompleted", "TaskBlocked",
|
||||
"WorkPhaseChanged", "ReviewRecorded", "TaskSubmitted",
|
||||
"TaskChangesRequested", "HumanDecisionRecorded", "ApprovalGranted",
|
||||
} {
|
||||
if Agent.CanEmit(typ) {
|
||||
t.Errorf("agent surface emitted %s", typ)
|
||||
}
|
||||
if err := AuthorizeEvent(Agent, typ); err == nil {
|
||||
t.Errorf("AuthorizeEvent(agent, %s) allowed", typ)
|
||||
}
|
||||
}
|
||||
if !Agent.CanEmit("ApprovalRequested") {
|
||||
t.Fatal("agent surface cannot ask")
|
||||
}
|
||||
if !Agent.CanRead() {
|
||||
t.Fatal("agent surface cannot read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSurfaceReachesOnlyRequestEndpoints(t *testing.T) {
|
||||
tokens := map[Surface]string{Agent: "agent-secret"}
|
||||
h := HTTPWithSessions(tokens, &Sessions{}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
do := func(method, path, auth string) int {
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
r.Header.Set("X-Orchestra-Surface", "agent")
|
||||
if auth != "" {
|
||||
r.Header.Set("Authorization", auth)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
const bearer = "Bearer agent-secret"
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
// May ask.
|
||||
{"/v1/tasks/t1/decision-request", http.StatusNoContent},
|
||||
{"/v1/tasks/t1/deferred", http.StatusNoContent},
|
||||
{"/v1/tasks/t1/approval", http.StatusNoContent},
|
||||
// May not act.
|
||||
{"/v1/tasks/t1/phase", http.StatusForbidden},
|
||||
{"/v1/tasks/t1/review", http.StatusForbidden},
|
||||
{"/v1/tasks/t1/submission", http.StatusForbidden},
|
||||
{"/v1/tasks/t1/complete", http.StatusForbidden},
|
||||
{"/v1/tasks/t1/lease", http.StatusForbidden},
|
||||
{"/v1/tasks/t1/release", http.StatusForbidden},
|
||||
{"/v1/tasks/t1/approval/grant", http.StatusForbidden},
|
||||
{"/v1/standup/apply", http.StatusForbidden},
|
||||
{"/v1/artifacts", http.StatusForbidden},
|
||||
} {
|
||||
if got := do(http.MethodPost, tc.path, bearer); got != tc.want {
|
||||
t.Errorf("POST %s = %d, want %d", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
// The token still gates the surface: no credential, no request endpoint.
|
||||
if got := do(http.MethodPost, "/v1/tasks/t1/decision-request", ""); got != http.StatusUnauthorized {
|
||||
t.Errorf("uncredentialed agent = %d, want 401", got)
|
||||
}
|
||||
// A session cookie must not authenticate an agent, and an agent token must
|
||||
// not authenticate the browser surface.
|
||||
if got := do(http.MethodGet, "/v1/tasks", "Bearer wrong"); got != http.StatusUnauthorized {
|
||||
t.Errorf("wrong agent token = %d, want 401", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The harness turn endpoint authenticates its own bearer token in the handler.
|
||||
// Before this exemption it defaulted to the session-gated Web surface, so every
|
||||
// harness call returned 401 in any deployment with web credentials configured.
|
||||
func TestHarnessTurnBypassesSurfaceGate(t *testing.T) {
|
||||
h := HTTPWithSessions(map[Surface]string{}, &Sessions{}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, HarnessTurnPath, nil))
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("harness turn = %d, want 204", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user