census-propose: Astra round 1 (one P1, five P2)
- P1: a new kind of read in a known unit is a decision ("reads"), not an
approval; "sites" is only more or fewer reads of kinds it already makes.
- P2: every census discrepancy no change explains is an "other" decision
with the census's message, so the card never shows less than the census
(removed declarations, the seal itself).
- P2: the dry-run card checks the seal (same mechanism).
- P2: a re-run proposes against the chain before its own overlay and
writes nothing until the new proposal is built and checked.
- P2: a re-run replaces only an overlay census-propose wrote (header mark).
- P2: CLAIMS paths with spaces survive the Makefile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md+11 −7Makefile+6 −4internal/provision/census_propose_internal_test.go+153 −9internal/provision/census_propose_test.go+100 −60internal/provision/plan14_root_reads_test.go+19 −4
CI cancelled — superseded by a newer push — run details
CI log
=== gate === .amadan/test-gate.sh === launchctx (0 s) ok amadan.net/carlos/platform.git/launchctx 0.012s ? amadan.net/carlos/platform.git/launchctx/controlname [no test files] ? amadan.net/carlos/platform.git/launchctx/wireid [no test files] === vet (19 s) === gofmt (0 s) gofmt -l .: clean === rest (175 s) ? github.com/carlosframework/platform/cmd/changelog [no test files] ok github.com/carlosframework/platform/cmd/console 4.085s ok github.com/carlosframework/platform/cmd/moveprobe 12.069s ok github.com/carlosframework/platform/cmd/zddprobe 0.812s ok github.com/carlosframework/platform/internal/activator 59.436s ok github.com/carlosframework/platform/internal/agentsock 0.056s ok github.com/carlosframework/platform/internal/alerting 0.008s ok github.com/carlosframework/platform/internal/analytics 0.050s ok github.com/carlosframework/platform/internal/appconfig 3.235s ok github.com/carlosframework/platform/internal/apperr 0.173s ok github.com/carlosframework/platform/internal/applog 0.339s ok github.com/carlosframework/platform/internal/awsinv 0.016s ok github.com/carlosframework/platform/internal/boxchoice 0.013s ok github.com/carlosframework/platform/internal/boxid 0.791s ? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files] ? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files] ok github.com/carlosframework/platform/internal/boxvolumes 0.027s ok github.com/carlosframework/platform/internal/caps 0.031s ok github.com/carlosframework/platform/internal/certwarm 0.006s ok github.com/carlosframework/platform/internal/cgroup 0.849s ok github.com/carlosframework/platform/internal/changelog 0.653s ok github.com/carlosframework/platform/internal/claimactivation 1.323s ok github.com/carlosframework/platform/internal/clamd 0.064s ? github.com/carlosframework/platform/internal/cliflag [no test files] ? github.com/carlosframework/platform/internal/controlname [no test files] ? github.com/carlosframework/platform/internal/controlsock [no test files] ok github.com/carlosframework/platform/internal/dbdoor 8.482s ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.030s ok github.com/carlosframework/platform/internal/dbquery 0.016s ok github.com/carlosframework/platform/internal/dbreads 2.158s ? github.com/carlosframework/platform/internal/dbreads/dbreadstest [no test files] ok github.com/carlosframework/platform/internal/dbverify 0.008s ok github.com/carlosframework/platform/internal/dnsrec 0.012s ok github.com/carlosframework/platform/internal/docsite 0.042s ok github.com/carlosframework/platform/internal/domains 2.941s ok github.com/carlosframework/platform/internal/doorkey 0.013s ok github.com/carlosframework/platform/internal/economics 0.015s ok github.com/carlosframework/platform/internal/edge 45.960s ? github.com/carlosframework/platform/internal/edgeproof [no test files] ok github.com/carlosframework/platform/internal/edgesfile 0.009s ok github.com/carlosframework/platform/internal/fleetapi 0.137s ok github.com/carlosframework/platform/internal/gate 1.655s ok github.com/carlosframework/platform/internal/genselect 0.017s ok github.com/carlosframework/platform/internal/geo 0.018s ok github.com/carlosframework/platform/internal/geoip 0.030s ok github.com/carlosframework/platform/internal/historyreadback 0.158s ok github.com/carlosframework/platform/internal/host 145.231s ok github.com/carlosframework/platform/internal/hostactors 5.952s ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.006s ok github.com/carlosframework/platform/internal/hostbox 3.105s ok github.com/carlosframework/platform/internal/hostclaim 0.835s ok github.com/carlosframework/platform/internal/hostenv 0.006s ok github.com/carlosframework/platform/internal/hostevidence 0.007s ok github.com/carlosframework/platform/internal/hostexec 5.411s ok github.com/carlosframework/platform/internal/hostfence 0.076s ok github.com/carlosframework/platform/internal/hostfiles 10.342s ok github.com/carlosframework/platform/internal/hostfloor 0.025s ok github.com/carlosframework/platform/internal/hostguard 1.112s ok github.com/carlosframework/platform/internal/hostops 2.032s ? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files] ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.003s ok github.com/carlosframework/platform/internal/hostquarantine 0.005s ok github.com/carlosframework/platform/internal/hostrelocate 0.684s ok github.com/carlosframework/platform/internal/hostresolve 0.224s ok github.com/carlosframework/platform/internal/hostwrite 0.005s ok github.com/carlosframework/platform/internal/infraview 0.003s ok github.com/carlosframework/platform/internal/instancectl 5.232s ok github.com/carlosframework/platform/internal/instanceexec 2.852s ok github.com/carlosframework/platform/internal/instancefs 0.005s ok github.com/carlosframework/platform/internal/instancekey 0.005s ok github.com/carlosframework/platform/internal/instanceupdates 0.011s ok github.com/carlosframework/platform/internal/layout 1.795s ok github.com/carlosframework/platform/internal/lbhealth 0.007s ok github.com/carlosframework/platform/internal/lease 10.281s ok github.com/carlosframework/platform/internal/lease/leasetest 0.572s ok github.com/carlosframework/platform/internal/ledger 0.022s ok github.com/carlosframework/platform/internal/mail 0.259s ok github.com/carlosframework/platform/internal/maintenance 0.095s ok github.com/carlosframework/platform/internal/maintenanceexec 5.578s ok github.com/carlosframework/platform/internal/maintenanceguard 0.111s ok github.com/carlosframework/platform/internal/metrics 0.006s ok github.com/carlosframework/platform/internal/moves 22.734s ok github.com/carlosframework/platform/internal/moves/archive 1.015s ok github.com/carlosframework/platform/internal/objstore 0.325s ok github.com/carlosframework/platform/internal/ops 0.197s ok github.com/carlosframework/platform/internal/park 0.300s ok github.com/carlosframework/platform/internal/plans 0.014s ok github.com/carlosframework/platform/internal/privatefile 0.008s ok github.com/carlosframework/platform/internal/provision 24.211s ok github.com/carlosframework/platform/internal/publicrelease 0.017s ? github.com/carlosframework/platform/internal/regionname [no test files] ok github.com/carlosframework/platform/internal/registry 5.681s ok github.com/carlosframework/platform/internal/relay 7.237s ok github.com/carlosframework/platform/internal/release 26.715s ok github.com/carlosframework/platform/internal/rollout 3.827s ok github.com/carlosframework/platform/internal/rolloutwords 0.003s ok github.com/carlosframework/platform/internal/rootaws 0.012s ok github.com/carlosframework/platform/internal/rootparticipation 0.068s ok github.com/carlosframework/platform/internal/rootstate 0.030s ok github.com/carlosframework/platform/internal/runs 0.611s ok github.com/carlosframework/platform/internal/s3compat 0.007s ok github.com/carlosframework/platform/internal/safefile 0.006s ok github.com/carlosframework/platform/internal/schedule 2.721s ok github.com/carlosframework/platform/internal/seeds 0.023s ok github.com/carlosframework/platform/internal/sim/engines 2.645s ok github.com/carlosframework/platform/internal/sim/hostlife 12.074s ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.689s ok github.com/carlosframework/platform/internal/status 0.067s ok github.com/carlosframework/platform/internal/steering 0.008s ok github.com/carlosframework/platform/internal/stopevidence 0.320s ok github.com/carlosframework/platform/internal/svcrole 0.003s ok github.com/carlosframework/platform/internal/telemetry 0.007s ok github.com/carlosframework/platform/internal/termfence 0.004s ok github.com/carlosframework/platform/internal/tunnelclient 0.004s ok github.com/carlosframework/platform/internal/unixdial 0.003s ok github.com/carlosframework/platform/internal/updatewords 0.006s ok github.com/carlosframework/platform/internal/usage 0.005s ok github.com/carlosframework/platform/internal/version 0.002s ok github.com/carlosframework/platform/internal/vet 1.681s === internal_console@0 (83 s) ok github.com/carlosframework/platform/internal/console 80.971s === internal_console@1 (44 s) ok github.com/carlosframework/platform/internal/console 42.271s === internal_console@2 (43 s) ok github.com/carlosframework/platform/internal/console 40.941s === internal_console@3 (42 s) ok github.com/carlosframework/platform/internal/console 40.370s === internal_console@4 (42 s) ok github.com/carlosframework/platform/internal/console 40.752s === internal_console@5 (42 s) ok github.com/carlosframework/platform/internal/console 40.472s === internal_console@6 (37 s) ok github.com/carlosframework/platform/internal/console 36.037s === internal_console@7 (47 s) ok github.com/carlosframework/platform/internal/console 45.616s === root@0 (110 s) ok github.com/carlosframework/platform 108.725s === root@1 (84 s) ok github.com/carlosframework/platform 83.026s === root@2 (92 s) ok github.com/carlosframework/platform 90.722s === cross === --- shard 0 of 5 --- cross-compiling darwin/arm64 amadan: job cancelled — a newer push superseded it --- shard 1 of 5 --- cross-compiling darwin/amd64 --- shard 2 of 5 --- cross-compiling linux/arm64 amadan: job cancelled — a newer push superseded it --- shard 3 of 5 --- cross-compiling windows/amd64 --- shard 4 of 5 --- cross-compiling windows/arm64 amadan: job cancelled — a newer push superseded it
Intent
<task-notification> <task-id>b1prnnc3b</task-id> <tool-use-id>toolu_01H9YCEDZuGKRuWgrcBSvdGu</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-platform/07894253-fbe8-42e3-8bd7-361304bbebdb/tasks/b1prnnc3b.output</output-file> <status>completed</status> <summary>Background command "Run Astra review of the census tool" completed (exit code 0)</summary> </task-notification>
<task-notification> <task-id>batqejw23</task-id> <tool-use-id>toolu_014K2TJMicFP1Qujd7ceHGkV</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-platform/07894253-fbe8-42e3-8bd7-361304bbebdb/tasks/batqejw23.output</output-file> <status>completed</status> <summary>Background command "Wait for CI on the branch tip" completed (exit code 0)</summary> </task-notification>
Attributed to this commit: 43.4 g CO₂e (5017256 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on census-approvals, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/AGENTS.md b/AGENTS.md| index 3d5091c3f..59be1f922 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -233,9 +233,11 @@ body, or an envelope fingerprint that moved. Get them approved like this: |
| 1. `make census-card` prints what changed and writes nothing. Each change |
| has a class: |
| - `body`: the digest moved and the reads did not; |
| - - `sites`: reads were added to or removed from a known unit; |
| + - `sites`: more or fewer reads of kinds the unit already makes; |
| - `envelope`: a package was re-pinned; |
| - - `new`, `removed` or `fields`: a decision. |
| + - `reads` (a kind of read the unit did not make before), `new`, |
| + `removed` or `other` (any census discrepancy none of these explains, |
| + shown with the census's own message): a decision. |
| 2. Write a CLAIMS file with one line per changed unit: |
| `<file>:<unit><TAB><what changed, and that no read was added or which one was>`. |
| 3. `make census-propose NAME=<branch> CLAIMS=<file>` writes the rest: |
| @@ -245,17 +247,19 @@ body, or an envelope fingerprint that moved. Get them approved like this: |
| - its line in `testdata/plan14-root-reads-chain.tsv`; |
| - the approval card, `docs/superpowers/notes/census-proposal-<NAME>.md`. |
| - It checks that the overlay leaves the census clean. If the code changes |
| - again, rerun it with the same NAME: it replaces its own overlay, which |
| - must still be the last line of the chain. |
| + It checks that the overlay leaves the census clean, and writes nothing |
| + if any step fails. If the code changes again, rerun it with the same |
| + NAME: it replaces its own overlay (only one it wrote itself), which must |
| + still be the last line of the chain. |
| 4. Show the card to the census owner and ask for an approval. Ask in the |
| session when they are there (one question: approve or hold); otherwise |
| post the card on #280. Record the approval on #280: who, when, and the |
| tip. Rows carry `rev3=census-ruling-<NAME>` from the start, so there is |
| nothing to flip afterwards and no second CI run. |
| -The tool refuses decisions: a `new` unit, a `removed` one, or a changed |
| -`fields` list. Write those rows by hand, following the overlays in the |
| +The tool refuses decisions (`reads`, `new`, `removed`, `other`): a new |
| +kind of read can make a unit's disposition wrong, so it is the owner's |
| +call, not an approval. Write those rows by hand, following the overlays in the |
| chain, and ask on #280 with the card attached; the census owner decides |
| those. Nothing about the census's strength changes: every changed read is |
| still named, digested and seen by a person before merge. The record-write |
diff --git a/Makefile b/Makefile| index 582243f7d..efae74f45 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -34,11 +34,13 @@ CENSUS_RUN = out=$$(mktemp "$${TMPDIR:-/tmp}/census.XXXXXX") || exit 1; \ |
| GOFLAGS="$${GOFLAGS:--buildvcs=false}" go test ./internal/provision/ -run '^TestPlan14RootReadCensusPropose$$' -count=1 -v >"$$out" 2>&1; rc=$$?; \ |
| if [ $$rc -eq 0 ]; then sed -n '/^--- census card ---$$/,/^--- end census card ---$$/p' "$$out" | sed '1d;$$d'; \ |
| else grep -v '^20[0-9][0-9]/' "$$out" | tail -20 >&2; fi; exit $$rc |
| -CENSUS_CLAIMS = $(if $(CLAIMS),CARLOS_CENSUS_CLAIMS=$(abspath $(CLAIMS))) |
| +# CLAIMS is kept as one value, spaces and all, and made absolute for go test. |
| +CENSUS_CLAIMS = c='$(CLAIMS)'; if [ -n "$$c" ]; then case "$$c" in /*) ;; *) c="$$PWD/$$c" ;; esac; \ |
| + CARLOS_CENSUS_CLAIMS="$$c"; export CARLOS_CENSUS_CLAIMS; fi |
| census-card: |
| - @export CARLOS_CENSUS_CARD=1 $(CENSUS_CLAIMS); $(CENSUS_RUN) |
| + @CARLOS_CENSUS_CARD=1; export CARLOS_CENSUS_CARD; $(CENSUS_CLAIMS); $(CENSUS_RUN) |
| census-propose: |
| - @if [ -z "$(NAME)" ] || [ -z "$(CLAIMS)" ]; then echo "usage: make census-propose NAME=<branch> CLAIMS=<file>" >&2; exit 2; fi; \ |
| - export CARLOS_CENSUS_PROPOSE=$(NAME) $(CENSUS_CLAIMS); $(CENSUS_RUN) |
| + @if [ -z '$(NAME)' ] || [ -z '$(CLAIMS)' ]; then echo "usage: make census-propose NAME=<branch> CLAIMS=<file>" >&2; exit 2; fi; \ |
| + CARLOS_CENSUS_PROPOSE='$(NAME)'; export CARLOS_CENSUS_PROPOSE; $(CENSUS_CLAIMS); $(CENSUS_RUN) |
diff --git a/internal/provision/census_propose_internal_test.go b/internal/provision/census_propose_internal_test.go| index 03615d57c..979aea417 100644 |
| --- a/internal/provision/census_propose_internal_test.go |
| +++ b/internal/provision/census_propose_internal_test.go |
| @@ -40,22 +40,22 @@ func change(got *rootReadScan, key, kinds, digest string) { |
| } |
| func TestCensusClassifiesEachKindOfChange(t *testing.T) { |
| - got, inv, _ := proposeFixture(t) |
| + got, inv, seal := proposeFixture(t) |
| // body: the same reads at moved offsets. |
| change(&got, "internal/a/a.go:Wake", "registry:Host@14,registry:DBPath@24", strings.Repeat("3", 64)) |
| - // sites: a read added. |
| + // reads: a kind of read the unit did not make. |
| change(&got, "internal/a/b.go:Route", "registry:Host@5,registry:Socket@9", strings.Repeat("4", 64)) |
| got.Coverage["internal/a"] = strings.Repeat("d", 64) |
| got.Entries = append(got.Entries, rootReadEntry{File: "internal/a/c.go", Unit: "New", Kinds: "file:Open@1", Digest: strings.Repeat("5", 64)}) |
| got.Declarations["internal/a/c.go:New"] = strings.Repeat("5", 64) |
| classes := map[string]string{} |
| - for _, c := range classifyRootReads(got, inv) { |
| + for _, c := range classifyRootReads(got, inv, seal) { |
| classes[c.Key] = c.Class |
| } |
| want := map[string]string{ |
| "internal/a/a.go:Wake": censusBody, |
| - "internal/a/b.go:Route": censusSites, |
| + "internal/a/b.go:Route": censusReads, |
| "internal/a": censusEnvelope, |
| "internal/a/c.go:New": censusNew, |
| } |
| @@ -69,11 +69,63 @@ func TestCensusClassifiesEachKindOfChange(t *testing.T) { |
| } |
| } |
| +// sites: another read of a kind the unit already makes. |
| +func TestCensusClassifiesMoreReadsOfAKnownKindAsSites(t *testing.T) { |
| + got, inv, seal := proposeFixture(t) |
| + change(&got, "internal/a/b.go:Route", "registry:Host@5,registry:Host@9", strings.Repeat("4", 64)) |
| + cs := classifyRootReads(got, inv, seal) |
| + if len(cs) != 1 || cs[0].Class != censusSites { |
| + t.Fatalf("changes = %+v, want one sites", cs) |
| + } |
| +} |
| + |
| +// The card never shows less than the census: a discrepancy no unit or |
| +// envelope change explains is an "other" decision with the census's own |
| +// message (Astra review, census-approvals P2). |
| +func TestCensusClassifiesEveryOtherDiscrepancy(t *testing.T) { |
| + got, inv, seal := proposeFixture(t) |
| + for name, tc := range map[string]struct { |
| + edit func(*rootReadScan, *rootReadInventory) string |
| + want string |
| + }{ |
| + "a wrong seal": { |
| + edit: func(*rootReadScan, *rootReadInventory) string { return strings.Repeat("e", 64) }, |
| + want: "changed legacy freeze", |
| + }, |
| + "a removed declaration back in the tree": { |
| + edit: func(g *rootReadScan, i *rootReadInventory) string { |
| + i.Removed = map[string]string{"internal/a/z.go:Gone": "E4;census-ruling-x"} |
| + g.Declarations["internal/a/z.go:Gone"] = strings.Repeat("6", 64) |
| + return seal |
| + }, |
| + want: "internal/a/z.go:Gone", |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + g, i := got, cloneRootReadInventory(inv) |
| + g.Declarations = cloneMap(got.Declarations) |
| + s := tc.edit(&g, &i) |
| + cs := classifyRootReads(g, i, s) |
| + if len(cs) == 0 { |
| + t.Fatal("classified nothing") |
| + } |
| + for _, c := range cs { |
| + if c.Class != censusOther || !strings.Contains(c.Key, tc.want) { |
| + t.Fatalf("changes = %+v, want an other carrying %q", cs, tc.want) |
| + } |
| + } |
| + if card := rootReadCard("x", cs, nil, s, ""); strings.Contains(card, "Nothing to approve") || !strings.Contains(card, tc.want) { |
| + t.Errorf("card:\n%s", card) |
| + } |
| + }) |
| + } |
| +} |
| + |
| func TestCensusClassifiesARemovedUnit(t *testing.T) { |
| - got, inv, _ := proposeFixture(t) |
| + got, inv, seal := proposeFixture(t) |
| got.Entries = got.Entries[:1] |
| delete(got.Declarations, "internal/a/b.go:Route") |
| - cs := classifyRootReads(got, inv) |
| + cs := classifyRootReads(got, inv, seal) |
| if len(cs) != 1 || cs[0].Class != censusRemoved || cs[0].Key != "internal/a/b.go:Route" { |
| t.Fatalf("changes = %+v, want one removed", cs) |
| } |
| @@ -84,14 +136,14 @@ func TestCensusClassifiesARemovedUnit(t *testing.T) { |
| func TestCensusProposalClosesEveryApprovableChange(t *testing.T) { |
| got, inv, seal := proposeFixture(t) |
| change(&got, "internal/a/a.go:Wake", "registry:Host@14,registry:DBPath@24", strings.Repeat("3", 64)) |
| - change(&got, "internal/a/b.go:Route", "registry:Host@5,registry:Socket@9", strings.Repeat("4", 64)) |
| + change(&got, "internal/a/b.go:Route", "registry:Host@5,registry:Host@9", strings.Repeat("4", 64)) |
| got.Coverage["internal/a"] = strings.Repeat("d", 64) |
| if len(censusDiscrepancies(got, inv, seal)) == 0 { |
| t.Fatal("fixture change made no discrepancy") |
| } |
| claims := map[string]string{ |
| "internal/a/a.go:Wake": "logs the wake's timing; no read added", |
| - "internal/a/b.go:Route": "reads the route's socket to log it", |
| + "internal/a/b.go:Route": "reads the route's host again to log it", |
| } |
| p, err := proposeRootReads(got, inv, seal, "wake-timing", claims) |
| if err != nil { |
| @@ -116,7 +168,7 @@ func TestCensusProposalClosesEveryApprovableChange(t *testing.T) { |
| t.Errorf("row does not name the ruling: %s", line) |
| } |
| } |
| - for _, want := range []string{"census-ruling-wake-timing", "internal/a/a.go", "body", "sites", "+registry:Socket", "internal/a", seal, p.Seal, "logs the wake's timing"} { |
| + for _, want := range []string{"census-ruling-wake-timing", "internal/a/a.go", "body", "sites", "+registry:Host", "internal/a", seal, p.Seal, "logs the wake's timing"} { |
| if !strings.Contains(p.Card, want) { |
| t.Errorf("card lacks %q:\n%s", want, p.Card) |
| } |
| @@ -143,6 +195,13 @@ func TestCensusProposalRefuses(t *testing.T) { |
| claims: map[string]string{"internal/a/a.go:Wake": "x", "internal/a/z.go:Typo": "y"}, |
| want: "not a change", |
| }, |
| + "a new kind of read": { |
| + edit: func(g *rootReadScan) { |
| + change(g, "internal/a/b.go:Route", "registry:Host@5,file:Open@9", strings.Repeat("4", 64)) |
| + }, |
| + claims: map[string]string{"internal/a/b.go:Route": "opens a file"}, |
| + want: "decision", |
| + }, |
| "a new unit": { |
| edit: func(g *rootReadScan) { |
| g.Entries = append(g.Entries, rootReadEntry{File: "internal/a/c.go", Unit: "New", Kinds: "file:Open@1", Digest: strings.Repeat("5", 64)}) |
| @@ -191,3 +250,88 @@ func TestCensusClaimsFile(t *testing.T) { |
| t.Fatal("a line without a tab was accepted") |
| } |
| } |
| + |
| +// writeFixtureRoot is a tree with an empty chain manifest and the notes dir. |
| +func writeFixtureRoot(t *testing.T) string { |
| + t.Helper() |
| + root := t.TempDir() |
| + for _, d := range []string{"internal/provision/testdata", "docs/superpowers/notes"} { |
| + if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := os.WriteFile(filepath.Join(root, rootReadChainManifest), []byte("# manifest\n"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + return root |
| +} |
| + |
| +func readFile(t *testing.T, path string) string { |
| + t.Helper() |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(raw) |
| +} |
| + |
| +func TestCensusProposalWritesAndReplacesItsOwnOverlay(t *testing.T) { |
| + root := writeFixtureRoot(t) |
| + first := rootReadProposal{Overlay: "# Census overlay x, " + censusProposeMark + ".\nrow1\n", Seal: strings.Repeat("a", 64), Card: "card 1"} |
| + if err := writeRootReadProposal(root, "x", first); err != nil { |
| + t.Fatal(err) |
| + } |
| + second := rootReadProposal{Overlay: "# Census overlay x, " + censusProposeMark + ".\nrow2\n", Seal: strings.Repeat("b", 64), Card: "card 2"} |
| + if err := writeRootReadProposal(root, "x", second); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, want := readFile(t, filepath.Join(root, rootReadChainManifest)), "# manifest\nplan14-root-reads-x.tsv\t"+strings.Repeat("b", 64)+"\n"; got != want { |
| + t.Errorf("manifest = %q, want %q", got, want) |
| + } |
| + if got := readFile(t, filepath.Join(root, "internal/provision/testdata/plan14-root-reads-x.tsv")); got != second.Overlay { |
| + t.Errorf("overlay = %q", got) |
| + } |
| + if got := readFile(t, filepath.Join(root, "docs/superpowers/notes/census-proposal-x.md")); got != "card 2" { |
| + t.Errorf("card = %q", got) |
| + } |
| +} |
| + |
| +func TestCensusProposalRefusesWhatItDidNotWrite(t *testing.T) { |
| + for name, setup := range map[string]func(t *testing.T, root string){ |
| + "a hand-written overlay of that name": func(t *testing.T, root string) { |
| + if err := os.WriteFile(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-x.tsv"), []byte("# Census overlay x (by hand)\n"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + "its overlay followed by another": func(t *testing.T, root string) { |
| + mine := rootReadProposal{Overlay: "# " + censusProposeMark + "\n", Seal: strings.Repeat("a", 64)} |
| + if err := writeRootReadProposal(root, "x", mine); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := writeRootReadProposal(root, "y", mine); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + root := writeFixtureRoot(t) |
| + setup(t, root) |
| + manifest := readFile(t, filepath.Join(root, rootReadChainManifest)) |
| + overlay, _ := os.ReadFile(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-x.tsv")) |
| + card, _ := os.ReadFile(filepath.Join(root, "docs/superpowers/notes/census-proposal-x.md")) |
| + p := rootReadProposal{Overlay: "# " + censusProposeMark + "\nnew\n", Seal: strings.Repeat("c", 64), Card: "card"} |
| + if err := writeRootReadProposal(root, "x", p); err == nil { |
| + t.Fatal("replaced an overlay it should not have") |
| + } |
| + if got := readFile(t, filepath.Join(root, rootReadChainManifest)); got != manifest { |
| + t.Errorf("manifest changed: %q", got) |
| + } |
| + if got, _ := os.ReadFile(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-x.tsv")); string(got) != string(overlay) { |
| + t.Errorf("overlay changed: %q", got) |
| + } |
| + if got, _ := os.ReadFile(filepath.Join(root, "docs/superpowers/notes/census-proposal-x.md")); string(got) != string(card) { |
| + t.Errorf("card changed: %q", got) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/provision/census_propose_test.go b/internal/provision/census_propose_test.go| index 1842d7d3e..904af347a 100644 |
| --- a/internal/provision/census_propose_test.go |
| +++ b/internal/provision/census_propose_test.go |
| @@ -31,15 +31,20 @@ import ( |
| // Change classes. The first three are approvals; the rest are decisions. |
| const ( |
| censusBody = "body" // digest moved, the same reads (offsets aside) |
| - censusSites = "sites" // reads added or removed in a known unit |
| + censusSites = "sites" // more or fewer reads of kinds the unit already makes |
| censusEnvelope = "envelope" // a package's source fingerprint moved |
| + censusReads = "reads" // a kind of read the unit did not make before |
| censusNew = "new" // an unclassified unit: needs a disposition |
| censusRemoved = "removed" // a unit or envelope gone: needs a removal row |
| - censusFields = "fields" // the scanner's field list changed |
| + censusOther = "other" // a census discrepancy no change above explains |
| ) |
| +// censusDecision: a new kind of read can make a unit's disposition wrong |
| +// (a registry reader that starts opening files is not the unit that was |
| +// classified), so it is the owner's call, as a new or removed unit is |
| +// (Astra review, census-approvals P1). |
| func censusDecision(class string) bool { |
| - return class == censusNew || class == censusRemoved || class == censusFields |
| + return class == censusReads || class == censusNew || class == censusRemoved || class == censusOther |
| } |
| type censusChange struct { |
| @@ -107,9 +112,24 @@ func sameKinds(before, after string) bool { |
| return kindDiff(before, after) == kindDiff(after, after) |
| } |
| -// classifyRootReads names every discrepancy compareRootReads and |
| -// checkRootReadDeclarations would report, one change per unit or envelope. |
| -func classifyRootReads(got rootReadScan, inv rootReadInventory) []censusChange { |
| +// newKinds reports whether after reads a kind before did not. |
| +func newKinds(before, after string) bool { |
| + b := kindCounts(before) |
| + for k := range kindCounts(after) { |
| + if b[k] == 0 { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// classifyRootReads names every discrepancy the census would report under |
| +// seal, one change per unit or envelope. A discrepancy no unit or envelope |
| +// change accounts for (a removed declaration back in the tree, the seal |
| +// itself, the scanner's field list) is an "other" decision carrying the |
| +// census's own message, so the card never shows less than the census does |
| +// (Astra review, census-approvals P2). |
| +func classifyRootReads(got rootReadScan, inv rootReadInventory, seal string) []censusChange { |
| var cs []censusChange |
| seen := map[string]bool{} |
| for _, g := range got.Entries { |
| @@ -122,6 +142,8 @@ func classifyRootReads(got rootReadScan, inv rootReadInventory) []censusChange { |
| case w.Kinds == g.Kinds && w.Digest == g.Digest && declarationHolds(got, inv, key): |
| case sameKinds(w.Kinds, g.Kinds): |
| cs = append(cs, censusChange{Key: key, Class: censusBody, Before: w, After: g}) |
| + case newKinds(w.Kinds, g.Kinds): |
| + cs = append(cs, censusChange{Key: key, Class: censusReads, Before: w, After: g}) |
| default: |
| cs = append(cs, censusChange{Key: key, Class: censusSites, Before: w, After: g}) |
| } |
| @@ -150,8 +172,21 @@ func classifyRootReads(got rootReadScan, inv rootReadInventory) []censusChange { |
| cs = append(cs, censusChange{Key: dir, Class: censusRemoved, OldHash: hash}) |
| } |
| } |
| - if f := strings.Join(got.Fields, ","); f != inv.Fields { |
| - cs = append(cs, censusChange{Key: "@fields", Class: censusFields}) |
| + keys := map[string]bool{} |
| + for _, c := range cs { |
| + keys[c.Key] = true |
| + } |
| + for _, msg := range censusDiscrepancies(got, inv, seal) { |
| + explained := false |
| + for _, tok := range strings.Fields(msg) { |
| + if keys[strings.TrimRight(tok, ";,")] { |
| + explained = true |
| + break |
| + } |
| + } |
| + if !explained { |
| + cs = append(cs, censusChange{Key: msg, Class: censusOther}) |
| + } |
| } |
| sort.Slice(cs, func(i, j int) bool { |
| if cs[i].Class != cs[j].Class { |
| @@ -190,7 +225,7 @@ func proposeRootReads(got rootReadScan, inv rootReadInventory, seal, name string |
| if !censusName.MatchString(name) { |
| return rootReadProposal{}, fmt.Errorf("census name %q: want lower-case letters, digits and dashes", name) |
| } |
| - cs := classifyRootReads(got, inv) |
| + cs := classifyRootReads(got, inv, seal) |
| if len(cs) == 0 { |
| return rootReadProposal{}, fmt.Errorf("nothing to propose: the census is clean") |
| } |
| @@ -238,7 +273,7 @@ func proposeRootReads(got rootReadScan, inv rootReadInventory, seal, name string |
| plain = append(plain, row) |
| } |
| header := []string{ |
| - fmt.Sprintf("# Census overlay %s, written by census-propose (#369). %s, approved by the census owner before the merge; the approval card is docs/superpowers/notes/census-proposal-%s.md.", name, ruling, name), |
| + fmt.Sprintf("# Census overlay %s, "+censusProposeMark+". %s, approved by the census owner before the merge; the approval card is docs/superpowers/notes/census-proposal-%s.md.", name, ruling, name), |
| fmt.Sprintf("# %d body, %d sites, %d envelope: each changed unit re-frozen at its existing disposition with its claim appended.", counts[censusBody], counts[censusSites], counts[censusEnvelope]), |
| } |
| overlay := strings.Join(append(append(append(header, frozen...), plain...), cover...), "\n") + "\n" |
| @@ -316,8 +351,11 @@ func rootReadCard(name string, cs []censusChange, claims map[string]string, seal |
| b.WriteString("Decisions (write their rows by hand and ask on #280):\n\n") |
| for _, c := range decisions { |
| detail := "" |
| - if c.Class == censusNew { |
| + switch c.Class { |
| + case censusNew: |
| detail = ": reads " + kindDiff("", c.After.Kinds) |
| + case censusReads: |
| + detail = ": " + kindDiff(c.Before.Kinds, c.After.Kinds) |
| } |
| fmt.Fprintf(&b, "- %s `%s`%s\n", c.Class, c.Key, detail) |
| } |
| @@ -331,6 +369,10 @@ func rootReadCard(name string, cs []censusChange, claims map[string]string, seal |
| return b.String() |
| } |
| +// censusProposeMark is in the first line of every overlay census-propose |
| +// writes: a re-run replaces only a file that carries it. |
| +const censusProposeMark = "written by census-propose (#369)" |
| + |
| func rootReadOverlayFile(name string) string { return "plan14-root-reads-" + name + ".tsv" } |
| func cloneRootReadInventory(inv rootReadInventory) rootReadInventory { |
| @@ -392,16 +434,21 @@ func TestPlan14RootReadCensusPropose(t *testing.T) { |
| t.Skip("run by make census-card and make census-propose") |
| } |
| root := censusRoot(t) |
| + // A re-run (the code changed again) proposes against the chain before |
| + // its own overlay, which must still be the manifest's last; nothing is |
| + // changed on disk until the new proposal is built and checked. |
| + before := "" |
| if !dry { |
| - // A re-run (the code changed again) replaces this name's own |
| - // overlay, which must still be the manifest's last. |
| - dropOwnRootReadLink(t, root, name) |
| + if !censusName.MatchString(name) { |
| + t.Fatalf("census name %q: want lower-case letters, digits and dashes", name) |
| + } |
| + before = rootReadOverlayFile(name) |
| } |
| got, err := rootReadProduction(root) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - inv, seal := rootReadChain(t, root) |
| + inv, seal := rootReadChainBefore(t, root, before) |
| claims := map[string]string{} |
| if path := os.Getenv("CARLOS_CENSUS_CLAIMS"); path != "" { |
| if claims, err = readCensusClaims(path); err != nil { |
| @@ -412,71 +459,64 @@ func TestPlan14RootReadCensusPropose(t *testing.T) { |
| if name == "" { |
| name = "<name>" |
| } |
| - fmt.Printf("%s\n%s%s\n", censusCardStart, rootReadCard(name, classifyRootReads(got, inv), claims, seal, ""), censusCardEnd) |
| + fmt.Printf("%s\n%s%s\n", censusCardStart, rootReadCard(name, classifyRootReads(got, inv, seal), claims, seal, ""), censusCardEnd) |
| return |
| } |
| p, err := proposeRootReads(got, inv, seal, name, claims) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - overlay := filepath.Join(root, "internal/provision/testdata", rootReadOverlayFile(name)) |
| - if err := os.WriteFile(overlay, []byte(p.Overlay), 0o644); err != nil { |
| - t.Fatal(err) |
| - } |
| - manifest := filepath.Join(root, rootReadChainManifest) |
| - f, err := os.OpenFile(manifest, os.O_APPEND|os.O_WRONLY, 0o644) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - if _, err := fmt.Fprintf(f, "%s\t%s\n", rootReadOverlayFile(name), p.Seal); err != nil { |
| - f.Close() |
| - t.Fatal(err) |
| - } |
| - if err := f.Close(); err != nil { |
| - t.Fatal(err) |
| - } |
| - note := filepath.Join(root, "docs/superpowers/notes", "census-proposal-"+name+".md") |
| - if err := os.WriteFile(note, []byte(p.Card), 0o644); err != nil { |
| + if err := writeRootReadProposal(root, name, p); err != nil { |
| t.Fatal(err) |
| } |
| fmt.Printf("%s\n%s%s\n", censusCardStart, p.Card, censusCardEnd) |
| } |
| -// dropOwnRootReadLink removes name's overlay from the end of the chain |
| -// manifest, and its file, so a re-run proposes against the chain before |
| -// it. One that is not last is refused: something was proposed after it. |
| -func dropOwnRootReadLink(t *testing.T, root, name string) { |
| - t.Helper() |
| +// writeRootReadProposal writes a checked proposal: the overlay, its line |
| +// at the end of the chain manifest (replacing its own line on a re-run) |
| +// and the approval card. It refuses, writing nothing, to replace an |
| +// overlay census-propose did not write, or one that is not the manifest's |
| +// last. |
| +func writeRootReadProposal(root, name string, p rootReadProposal) error { |
| + file := rootReadOverlayFile(name) |
| + overlay := filepath.Join(root, "internal/provision/testdata", file) |
| + if raw, err := os.ReadFile(overlay); err == nil { |
| + first, _, _ := strings.Cut(string(raw), "\n") |
| + if !strings.Contains(first, censusProposeMark) { |
| + return fmt.Errorf("%s exists and census-propose did not write it: propose under a new name", file) |
| + } |
| + } else if !os.IsNotExist(err) { |
| + return err |
| + } |
| manifest := filepath.Join(root, rootReadChainManifest) |
| + raw, err := os.ReadFile(manifest) |
| + if err != nil { |
| + return err |
| + } |
| + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") |
| links, err := readRootReadChainManifest(manifest) |
| if err != nil { |
| - t.Fatal(err) |
| + return err |
| } |
| - file := rootReadOverlayFile(name) |
| for i, l := range links { |
| if l.File != file { |
| continue |
| } |
| - if i != len(links)-1 { |
| - t.Fatalf("%s is not the last overlay in %s: propose under a new name", file, rootReadChainManifest) |
| - } |
| - raw, err := os.ReadFile(manifest) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") |
| - last := len(lines) - 1 |
| - if lines[last] != l.File+"\t"+l.Seal { |
| - t.Fatalf("%s: last line is %q, want %s's", rootReadChainManifest, lines[last], file) |
| - } |
| - if err := os.WriteFile(manifest, []byte(strings.Join(lines[:last], "\n")+"\n"), 0o644); err != nil { |
| - t.Fatal(err) |
| - } |
| - if err := os.Remove(filepath.Join(root, "internal/provision/testdata", file)); err != nil && !os.IsNotExist(err) { |
| - t.Fatal(err) |
| + if i != len(links)-1 || lines[len(lines)-1] != l.File+"\t"+l.Seal { |
| + return fmt.Errorf("%s is not the last overlay in %s: propose under a new name", file, rootReadChainManifest) |
| } |
| + lines = lines[:len(lines)-1] |
| } |
| - if _, err := os.Stat(filepath.Join(root, "internal/provision/testdata", file)); err == nil { |
| - t.Fatalf("%s exists but is not in %s: a hand-written overlay has that name", file, rootReadChainManifest) |
| + lines = append(lines, file+"\t"+p.Seal) |
| + note := filepath.Join(root, "docs/superpowers/notes", "census-proposal-"+name+".md") |
| + for _, w := range []struct{ path, text string }{ |
| + {overlay, p.Overlay}, |
| + {manifest, strings.Join(lines, "\n") + "\n"}, |
| + {note, p.Card}, |
| + } { |
| + if err := os.WriteFile(w.path, []byte(w.text), 0o644); err != nil { |
| + return err |
| + } |
| } |
| + return nil |
| } |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 61e3a9d1e..72cc46997 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -613,6 +613,14 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| // the overlays the chain manifest lists (rootReadManifest). It returns the |
| // inventory and the seal the last overlay leaves. |
| func rootReadChain(t testing.TB, root string) (rootReadInventory, string) { |
| + t.Helper() |
| + return rootReadChainBefore(t, root, "") |
| +} |
| + |
| +// rootReadChainBefore is rootReadChain without the manifest's last overlay |
| +// when that overlay is the file named last (a census-propose re-run |
| +// proposing against the chain before its own overlay); "" loads it all. |
| +func rootReadChainBefore(t testing.TB, root, last string) (rootReadInventory, string) { |
| t.Helper() |
| var err error |
| inv := rootReadInventory{Entries: map[string]classifiedRootRead{}, Coverage: map[string]string{}} |
| @@ -1478,7 +1486,7 @@ func rootReadChain(t testing.TB, root string) (rootReadInventory, string) { |
| t.Fatal(err) |
| } |
| seal = "09e20ea4bbc534710334e632e903878656dabfb5817d4d96c6e7c56508172148" |
| - return rootReadManifest(t, root, inv, seal) |
| + return rootReadManifest(t, root, inv, seal, last) |
| } |
| func TestPlan14RootReadCensusRejectsDrift(t *testing.T) { |
| @@ -1769,14 +1777,21 @@ func readRootReadChainManifest(path string) ([]rootReadChainLink, error) { |
| } |
| // rootReadManifest loads the manifest's overlays onto inv, each after the |
| -// seal before it is checked, exactly as rootReadChain loads its own. |
| -func rootReadManifest(t testing.TB, root string, inv rootReadInventory, seal string) (rootReadInventory, string) { |
| +// seal before it is checked, exactly as rootReadChain loads its own. A |
| +// non-empty last leaves that overlay out, and only when it is the last. |
| +func rootReadManifest(t testing.TB, root string, inv rootReadInventory, seal, last string) (rootReadInventory, string) { |
| t.Helper() |
| links, err := readRootReadChainManifest(filepath.Join(root, rootReadChainManifest)) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - for _, l := range links { |
| + for i, l := range links { |
| + if last != "" && l.File == last { |
| + if i != len(links)-1 { |
| + t.Fatalf("%s is not the last overlay in %s: propose under a new name", last, rootReadChainManifest) |
| + } |
| + break |
| + } |
| if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| t.Fatalf("before overlay %s: %v", l.File, errs) |
| } |