carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 696024af1976bb98aebdcf0762d6feaf1903cd5e parent fd9de85
5 files changed, +289 −84
  • AGENTS.md +11 −7
  • Makefile +6 −4
  • internal/provision/census_propose_internal_test.go +153 −9
  • internal/provision/census_propose_test.go +100 −60
  • internal/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

Human

<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>

Human

<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)
}