Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/site/concepts/gates-and-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ so the two shapes stay distinguishable.
Stable v1 gate reviews may appear in chat or Subspace. Before presenting one, the first officer commits
newly authored selected sources and calls `gate prepare` with its question, primary
Markdown review, exact concise summary, and References. Spacedock authors and binds a
two-file recorder-ready room; the first officer commits that entity-owned room. The
one-file recorder-ready room; the first officer commits that entity-owned room. The
selected source payloads remain singular local Git objects rather than room copies.

Both presentation interfaces return semantic decision and reason input to the first
Expand Down
2 changes: 1 addition & 1 deletion docs/site/reference/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ The first officer runs these against workflow state as it moves entities; you op
| Command | What it does |
|---------|--------------|
| `spacedock status` | Read or mutate the state: the default entity table is sorted by later workflow stage first then score descending, paginated to 25 rows by default in the human table (`--page N`, `--limit N`, `--limit 0` for all) while `--json` returns every row unless `--page`/`--limit` selects a window, omits the SOURCE column by default (`--fields source` or `--all-fields` restores it), `--next`, `--where`, `--set`, `--validate` (which also warns when a flat entity holds gate rooms, and when a retained `room-ref` no longer resolves), `--boot` (with `--identify`, the first officer's Startup identify — discovers the managed workflow(s), folds in the stage taxonomy and canonical ready-gate scheduling rows, and reports the boot sections; each row carries only `id`, `slug`, `current`, and `readiness`, while entity read and gate commands provide the complete decision record at engage; a gate stage alone is not ready; PR_STATE is a local `pr:` view, live PR state is checked at engage; local reads only, no mutation), `--read <ref-or-path>` (a file's structured frontmatter — including the nested `stages:` taxonomy, and projectable with `--fields` — plus a heading offset/lines map, for section-scoped reads; with `--checklist` / `--ac-scan` it extracts a stage report's checklist items with line ranges and per-AC evidence citations for the first officer's gate prep; `--stage` defaults to the entity's current `status` when omitted (so a bare `--read <entity> --checklist` reads the current stage's report), and `--stage X` reads a non-current stage) |
| `spacedock gate prepare <entity> --question TEXT --artifact REVIEW.md --summary TEXT [--reference FILE ...]` | At an actionable current workflow stage (`gate: true` and nonterminal), derive and bind a recorder-ready room for folder or flat form. Where the workflow README declares `entity-form: folder`, a flat entity that holds no rooms yet is refused instead, because the room would land in a `<slug>/` companion and bind a ref that breaks if the entity later becomes `<slug>/index.md`; file it as `<slug>/index.md`. A workflow that declares no form accepts either shape, and a flat entity that already holds rooms is grandfathered under the declaration and prepares as before. At an ungated or terminal stage, preparation exits nonzero before changing the entity or review tree and names the rejected stage. Immediately after successful preparation the room contains exactly `gate-briefing.json` and `request.json`, with no copied sources or association. Selected files are exact local `git-root://<main\|state>/<full-commit>/<path>` objects with raw SHA-256 revisions; there is no fetch, ref requirement, or worktree fallback. Success prints `room`, `briefing`, `digest`, and `state=open`; the caller commits this prepared binding once before presentation. |
| `spacedock gate prepare <entity> --question TEXT --artifact REVIEW.md --summary TEXT [--reference FILE ...]` | At an actionable current workflow stage (`gate: true` and nonterminal), derive and bind a recorder-ready room for folder or flat form. Where the workflow README declares `entity-form: folder`, a flat entity that holds no rooms yet is refused instead, because the room would land in a `<slug>/` companion and bind a ref that breaks if the entity later becomes `<slug>/index.md`; file it as `<slug>/index.md`. A workflow that declares no form accepts either shape, and a flat entity that already holds rooms is grandfathered under the declaration and prepares as before. At an ungated or terminal stage, preparation exits nonzero before changing the entity or review tree and names the rejected stage. Immediately after successful preparation the room contains exactly `index.json`, the canonical Briefing, with no copied sources or association. Selected files are exact local `git-root://<main\|state>/<full-commit>/<path>` objects with raw SHA-256 revisions; there is no fetch, ref requirement, or worktree fallback. Success prints `room`, `briefing`, `digest`, and `state=open`; the caller commits this prepared binding once before presentation. |
| `spacedock gate withdraw <entity> --reason TEXT` | Retire the selected current-stage open prepared attempt without a Resolution, provider evidence, application, room write, or status change. Attribution is always `agent:first-officer`; the next `gate prepare` appends a successor. |
| `spacedock gate record <entity> --decision approve\|revise\|hold --actor ID [--reason TEXT] [--consume]` | Record a chat decision and, for approve only, its derived one-use application. Supported chat actor IDs are `person:captain` and `agent:first-officer`. Delegated First Officer decisions require an evidence reason; the recorder does not accept or authenticate Captain-message text. A standalone close never advances status or dispatches. The current workflow stage must be an actionable gate, and the bound Briefing must use the canonical v1 stage-qualified identity and name that stage; malformed or mismatched identity fails without mutation. `--consume` is the shortest approval path: it sequences close, sync, consume, and sync in one call (usage error with `--decision revise\|hold`), each write appending a `sync=... phase=record\|consume` line. Do not run a separate state commit after a successful split-root close or consume write. Delegated First Officer decisions also require `--conn-quote` (the grant verbatim) and `--conn-source` (where it was given); citation flags are refused with `--actor person:captain`. |
| `spacedock gate record <entity> --round STAGE/CYCLE --briefing PATH/briefing.json --log PATH/briefing.review.jsonl` | For a folder-form entity (`<slug>/index.md`), publish one complete workflow-neutral correction round to the immutable derived room `review/<stage>/round-<cycle>` and update the current `review-round` pointer. The recorder retains canonical Briefing/log bytes and does not classify findings or write workflow body projections; `STAGE` must exist in the workflow taxonomy but may differ from current status for historical backfill. Flat entities are refused because review artifacts accumulate beside the entity. Exact replay is a no-op; divergence is refused. |
Expand Down
24 changes: 16 additions & 8 deletions docs/specs/gate-resolution-frontmatter-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ chat or Subspace for presentation; Spacedock prepares and records authority.
flowchart TD
FO["First Officer<br/>selects Artifact and References<br/>authors question and summary"]
PREP["spacedock gate prepare<br/>derives IDs, digests, Git locators,<br/>authority, room, and binding"]
ROOM[("Frozen gate room<br/>request.json and canonical Briefing")]
ROOM[("Frozen gate room<br/>the canonical Briefing, one file")]
COMMIT_PREP["spacedock state commit<br/>publishes the prepared binding"]

CHANNEL{"Presentation interface"}
Expand Down Expand Up @@ -103,10 +103,17 @@ bytes. A request-backed room additionally freezes its
request digest; that request names the canonical Briefing with a clean room-relative
locator, id, and digest. No reader infers a canonical basename.

A prepared provider-neutral room binds `request-digest`, the JCS digest of its
`request.json`. Request-less and chat-only attempts may omit it. Changing the request,
located Briefing, or a selected Git object after the attempt binds therefore fails
before semantic decision recording or entity mutation.
A prepared room is one file: the canonical Briefing, named `index.json`. Its binding
carries no `request-digest`. A binding whose `room-ref` names a directory that does not
hold `briefing.json` is a prepared room. Rooms prepared before this change hold
`gate-briefing.json` and `request.json`, and bind the JCS digest of that request. They
stay readable, and they keep the full request validation. No room migrates. Changing the
located Briefing, the retained request, or a selected Git object after the attempt binds
therefore fails before semantic decision recording or entity mutation.

The recorder is the authority wall. A presentation channel materializes the room and can
recompute what it reads. Only the recorder compares the room against the entity binding,
under the entity lock. Every mutating verb runs that comparison before it writes.

## Provider-neutral preparation

Expand All @@ -122,7 +129,8 @@ The caller supplies exactly one question, Markdown primary Artifact, and nonblan
valid-UTF-8 primary summary; References may repeat in caller order. Spacedock preserves
the summary string exactly, assigns deterministic ordinal item identities, and derives
the gate, attempt, Briefing, Captain authority, digests, and room. It writes only
`gate-briefing.json` and `request.json` at preparation time. It copies no selected
`index.json` at preparation time. Readers resolve the canonical Briefing by name:
`index.json` first, then the earlier `gate-briefing.json`. Preparation copies no selected
source, writes no association, and creates no provider subtree.

The room layout is the same for both entity forms:
Expand Down Expand Up @@ -172,8 +180,8 @@ preparation appends a successor while earlier authority remains frozen; only a c
attempt may have a pending application to supersede.

`spacedock gate withdraw ENTITY --reason TEXT` retires only the selected current-stage
open request-backed attempt. Under the shared lock it validates all retained authority
and requires the room to contain exactly `gate-briefing.json` and `request.json`. It
open prepared attempt. Under the shared lock it validates all retained authority
and requires the room to contain exactly the file set its binding implies. It
records only `withdrawal: {by: agent:first-officer, at: <UTC>, reason: <TEXT>}`: no
Resolution, provider evidence, application, status change, successor, or room write.

Expand Down
120 changes: 120 additions & 0 deletions internal/cli/gate_one_file_room_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package cli

import (
"bytes"
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"

"github.com/spacedock-dev/spacedock/internal/gates"
"github.com/spacedock-dev/spacedock/internal/status"
)

// TestGateOneFileRoomJourneyPublishesAndCompletes is value AC-1. A gate journey
// publishes one file, and the gate still closes, consumes, and archives. Before
// this change the same journey published two files and bound a request-digest,
// in 499 of 499 rooms scanned. Make Prepare mint the request again and the
// entry-set assertion reds.
func TestGateOneFileRoomJourneyPublishesAndCompletes(t *testing.T) {
workflow, state, artifact := gateOneFileCLIFixture(t)
entity := filepath.Join(state, "task.md")

var out, errOut bytes.Buffer
invoke := func(args ...string) int {
out.Reset()
errOut.Reset()
return run(context.Background(), args, nil, workflow, nil, &out, &errOut, &status.NativeRunner{}, nil)
}

if code := invoke("gate", "prepare", "task",
"--question", "Advance?", "--artifact", artifact, "--summary", "one-file candidate",
"--workflow-dir", workflow); code != 0 {
t.Fatalf("prepare exit=%d stdout=%q stderr=%q", code, out.String(), errOut.String())
}
room := filepath.Join(state, "task", "review", "validation", "briefing-1")
if got := gateRoomEntryNames(t, room); !reflect.DeepEqual(got, []string{"index.json"}) {
t.Fatalf("room entries=%v want exactly [index.json]", got)
}
doc, _, err := gates.Read(entity)
if err != nil {
t.Fatal(err)
}
binding := doc.Records[0].Attempts[0].Briefing
if binding.RequestDigest != "" {
t.Fatalf("binding carries request-digest %q", binding.RequestDigest)
}
if binding.RoomRef != "./task/review/validation/briefing-1" {
t.Fatalf("room-ref=%q", binding.RoomRef)
}

if code := invoke("gate", "record", "task", "--decision", "approve",
"--actor", "person:captain", "--consume", "--workflow-dir", workflow); code != 0 {
t.Fatalf("close and consume exit=%d stdout=%q stderr=%q", code, out.String(), errOut.String())
}
if !strings.Contains(out.String(), "state=closed") || !strings.Contains(out.String(), "consumed=") {
t.Fatalf("close and consume stdout=%q want state=closed and consumed=", out.String())
}
if got := status.ParseFrontmatter(entity)["status"]; got != "implementation" {
t.Fatalf("consumed approval left status=%q want implementation", got)
}
doc, _, err = gates.Read(entity)
if err != nil {
t.Fatal(err)
}
attempt := doc.Records[0].Attempts[0]
if attempt.Resolution == nil || attempt.Resolution.Decision != "approve" ||
attempt.Application == nil || attempt.Application.State != "consumed" {
t.Fatalf("attempt did not close and consume: %#v", attempt)
}
// The archived one-file room stays readable to the read-only validator.
if got := gateRoomEntryNames(t, room); !reflect.DeepEqual(got, []string{"index.json"}) {
t.Fatalf("archived room entries=%v", got)
}
if code := invoke("status", "--workflow-dir", workflow, "--validate"); code != 0 {
t.Fatalf("validate over the archived room exit=%d stderr=%q", code, errOut.String())
}
}

func gateRoomEntryNames(t *testing.T, room string) []string {
t.Helper()
entries, err := os.ReadDir(room)
if err != nil {
t.Fatal(err)
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.Type().IsRegular() {
t.Fatalf("gate room holds non-regular entry %s", entry.Name())
}
names = append(names, entry.Name())
}
return names
}

// gateOneFileCLIFixture is gatePrepareCLIFixture with a nonterminal advance
// target, so an approved gate really consumes instead of parking on the terminal
// merge route. Only the stage list differs, so it reuses the shared fixture and
// re-commits the one changed file.
func gateOneFileCLIFixture(t *testing.T) (workflow, state, artifact string) {
t.Helper()
workflow, state, artifact = gatePrepareCLIFixture(t)
readme := filepath.Join(workflow, "README.md")
body, err := os.ReadFile(readme)
if err != nil {
t.Fatal(err)
}
staged := bytes.Replace(body, []byte(" - name: done\n"), []byte(" - name: implementation\n - name: done\n"), 1)
if bytes.Equal(staged, body) {
t.Fatal("shared fixture lost the done stage this helper inserts before")
}
if err := os.WriteFile(readme, staged, 0o644); err != nil {
t.Fatal(err)
}
mainRoot := filepath.Dir(filepath.Dir(workflow))
git(t, mainRoot, "add", "-A")
git(t, mainRoot, "commit", "-q", "-m", "nonterminal advance target")
return workflow, state, artifact
}
27 changes: 5 additions & 22 deletions internal/cli/gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ func TestGatePreparedBriefingLocatorLifecycleAndRefusals(t *testing.T) {
workflow, state, artifact := gatePrepareCLIFixture(t)
entity := filepath.Join(state, "task.md")
room := filepath.Join(state, "task", "review", "validation", "briefing-1")
briefing := filepath.Join(room, "gate-briefing.json")
briefing := filepath.Join(room, "index.json")
if code, out, errOut := invoke(t, workflow,
"gate", "prepare", "task",
"--question", "Advance?",
Expand Down Expand Up @@ -642,27 +642,10 @@ func TestGatePreparedBriefingLocatorLifecycleAndRefusals(t *testing.T) {
},
wants: []string{"bound canonical Briefing bytes", "frozen digest"},
},
{
name: "tampered request locator",
mutate: func(t *testing.T, fixture fixture) {
t.Helper()
requestPath := filepath.Join(fixture.room, "request.json")
body, err := os.ReadFile(requestPath)
if err != nil {
t.Fatal(err)
}
changed := bytes.Replace(body,
[]byte(`"locator": "gate-briefing.json"`),
[]byte(`"locator": "missing-gate-briefing.json"`), 1)
if bytes.Equal(changed, body) {
t.Fatal("prepared request locator fixture was not found")
}
if err := os.WriteFile(requestPath, changed, 0o644); err != nil {
t.Fatal(err)
}
},
wants: []string{"retained request.json", "frozen digest"},
},
// The retained two-file room and its frozen request locator are covered
// in internal/gates by TestRetainedTwoFileRoomKeepsItsFullValidation.
// Preparation no longer publishes that shape, so there is nothing for a
// CLI journey to build it from.
} {
t.Run(tc.name, func(t *testing.T) {
fixture := prepareClosed(t)
Expand Down
49 changes: 23 additions & 26 deletions internal/cli/terminal_consume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,25 @@ func approvedTerminalGate(t *testing.T, root string) {
}
}

// disturbRetainedGateRoom tampers the retained canonical Briefing of the room
// approvedTerminalGate published, so the frozen digest no longer matches. The
// Briefing is the whole retained authority of a one-file room.
func disturbRetainedGateRoom(t *testing.T, root string) {
t.Helper()
var briefingFile string
if err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() && info.Name() == "index.json" {
briefingFile = p
}
return nil
}); err != nil || briefingFile == "" {
t.Fatalf("locate retained index.json: %v %q", err, briefingFile)
}
if err := os.WriteFile(briefingFile, []byte("{\"type\":\"tampered\"}\n"), 0o644); err != nil {
t.Fatalf("disturb briefing room: %v", err)
}
}

func entityFields(t *testing.T, entity string) map[string]string {
t.Helper()
return status.ParseFrontmatter(entity)
Expand Down Expand Up @@ -474,18 +493,7 @@ func TestTerminalSetRefusedOnUnclassifiableAuthority(t *testing.T) {
t.Fatalf("consume exit=%d stdout=%q stderr=%q", code, out, errOut)
}
// Disturb the retained briefing room: digest-stale authority.
var requestFile string
if err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() && info.Name() == "request.json" {
requestFile = p
}
return nil
}); err != nil || requestFile == "" {
t.Fatalf("locate retained request.json: %v %q", err, requestFile)
}
if err := os.WriteFile(requestFile, []byte("tampered"), 0o644); err != nil {
t.Fatalf("disturb briefing room: %v", err)
}
disturbRetainedGateRoom(t, root)
before, err := os.ReadFile(entity)
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -524,20 +532,9 @@ func TestMergeGuardRefusesDigestStaleAuthorityByteClean(t *testing.T) {
if code, out, errOut := terminalInvoke(t, root, "status", "--workflow-dir", root, "--set", "task", "pr=pr-merge:7"); code != 0 {
t.Fatalf("record merge sentinel exit=%d stdout=%q stderr=%q", code, out, errOut)
}
// THEN disturb the retained briefing room recorded at prepare: tamper the
// room's retained request.json so its frozen digest no longer matches.
var requestFile string
if err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() && info.Name() == "request.json" {
requestFile = p
}
return nil
}); err != nil || requestFile == "" {
t.Fatalf("locate retained request.json: %v %q", err, requestFile)
}
if err := os.WriteFile(requestFile, []byte("tampered"), 0o644); err != nil {
t.Fatalf("disturb briefing room: %v", err)
}
// THEN disturb the retained briefing room recorded at prepare, so its
// frozen digest no longer matches.
disturbRetainedGateRoom(t, root)
before, err := os.ReadFile(entity)
if err != nil {
t.Fatal(err)
Expand Down
Loading
Loading