Skip to content

docs: define the refusal class routing contract - #94

Merged
Kiran01bm merged 5 commits into
mainfrom
kiran01bm/vt1-refusal-class-contract
Sep 9, 2026
Merged

docs: define the refusal class routing contract#94
Kiran01bm merged 5 commits into
mainfrom
kiran01bm/vt1-refusal-class-contract

Conversation

@Kiran01bm

@Kiran01bm Kiran01bm commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Add a machine-readable refusal class contract so consumers can route every refusal without reinterpreting overloaded reason strings.

Why

Existing refusal reasons explain the immediate cause but conflate capability gaps, work owned by another tool class, permanent safety decisions, run-site conditions, and pg-sprite's own defects. unsupported-statement and unsupported-partitioned-parent each span several of those categories, and the split runs one level below the reason: along the typed cause (PartitionRefusalCause, CreateShapeCause, admission sentinels) that the code already carries.

What

  • Define five additive classes: capability-boundary, no-online-safety-problem, by-design, environmental, and invariant-violation (the refusal-verdict face of the fail-closed ErrInvariantViolation rule).
  • Define structured owner values for no-online-safety-problem routing: data-change-runner, provisioning, direct-operator (the ⚪ half of T3), declarative-front-door.
  • Key the map on the typed cause where one exists and on the refusal site where none does: one table per cause set, plus the site-keyed table for the rest. Name the parse-boundary work (pkg/statement distinguishing the kinds behind the KindOther catch-all) that classification requires.
  • Pin the map to the code: docs_test.go guards in pkg/verdict, pkg/executor, and pkg/preflight fail when a Reason, CreateShapeCause, or PartitionRefusalCause exists without a row (adds preflight.PartitionRefusalCauses()).
  • Cite RF-5 (what by-design encodes) and RF-6 (which already separates the not-implemented from the version-gated partitioned-parent case); name the invariant rollout step 2 adds once the field ships.
  • Bring capabilities-contract.md's shared-vocabulary section into agreement (fifth class, direct-operator), and add the design to the documentation index.

Ordering: #95 (capabilities contract) has merged, so the cross-link here is live. #96 (CI runs Go tests on docs-only PRs) is independent; this PR touches Go test files, so full CI runs on it regardless.

Before / after

Two refusals with the same reason, unsupported-partitioned-parent, on PostgreSQL 16:

Before: the consumer sees one reason and cannot tell the two apart

  CREATE INDEX ... ON parts_parent (col)           ALTER TABLE parts_parent
      │                                              ADD FOREIGN KEY ... NOT VALID
      ▼                                                  │
  refused, reason: unsupported-partitioned-parent        ▼
  (the partition-aware build is not implemented yet)  refused, reason: unsupported-partitioned-parent
      │                                              (PostgreSQL 16 cannot do this; 18 can)
      ▼                                                  │
  consumer: "unsupported" — wait? upgrade? give up?      ▼
                                                     consumer: the same guess, same reason string

After: the class is read off the typed cause, so each refusal names its own next step

  CREATE INDEX ... ON parts_parent (col)           ALTER TABLE parts_parent
      │                                              ADD FOREIGN KEY ... NOT VALID
      ▼                                                  │
  cause: parent-concurrent-index-build                   ▼
      │                                              cause: parent-not-valid-foreign-key
      ▼                                                  │
  reason: unsupported-partitioned-parent                 ▼
  class:  capability-boundary                        reason: unsupported-partitioned-parent
      │                                              class:  environmental
      ▼                                                  │
  consumer: wait for the pg-sprite release               ▼
            that adds the partition-aware flow       consumer: upgrade the server to 18; same
                                                               statement and same pg-sprite then run

The other cause sets follow the same rule: a CREATE INDEX CONCURRENTLY on a table born this run is by-design (use the plain build), and a planRefusal for a disposition this build does not know is invariant-violation (report a pg-sprite bug, do not wait or retry).

🤖 Drafted with Amp (Claude Opus 4.6); reviewed and edited by the author.

@Kiran01bm
Kiran01bm marked this pull request as ready for review September 9, 2026 22:27
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@aparajon

aparajon commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

🤖 Reviewed 06a0311f against merge base 7fec08bd (2 files, +186/−0). Comment 1 of 2 — the map itself; the second covers enforcement, the doc stack, and invariants.

The decision is right and the reasoning for it is the strongest part of the doc. Splitting routing from cause instead of renaming tokens or minting exit codes is the correct shape, the rejected alternatives are recorded with why they lost rather than just listed, and owner as a closed enum rather than prose is what makes the no-online-safety-problem class actionable instead of merely descriptive. The opening claim checks out exactly: unsupported-statement really does cover all four of the things named — the KindOther DML path (pkg/migrate/verdicts.go:37), imperative CREATE TABLE (:31), ErrIfNotExistsUnsupported, and routeRefusalVerdict's "the planner knows no safe path" (:123). verdict.Reasons() (pkg/verdict/verdict.go:102) is the closed set of 11 tokens the doc says it is, and every one of them has a row.

I checked the map row by row against the emission sites. Eight of the eleven reasons classify cleanly and I would not change them. The three that do not are the three that carry a second typed discriminator underneath the reason, and that is not a coincidence — it is the same structural problem in three places.

1. unsupported-partitioned-parent is mapped to one class but its four causes span three — and one of them contradicts this doc's own matrix table

Row :56 maps the reason to capability-boundary, "the partition-aware online sequence is not implemented". That is true of two of the four PartitionRefusalCause values (pkg/preflight/partition.go:12-26) and false of the other two:

Cause What the error says Row :56 says Should be
parent-concurrent-index-build pg-sprite does not yet support the CREATE INDEX ON ONLY flow capability-boundary
parent-blocking-index-build the partition-aware concurrent flow is not yet supported capability-boundary
parent-index-adoption "PostgreSQL does not support … on partitioned tables in any supported version" capability-boundary by-design
parent-not-valid-foreign-key "PostgreSQL before version 18 cannot …" capability-boundary environmental

Both misroutes are consequential, and the second is the one I would fix first. limitations.md:14 states the NOT VALID foreign key "is supported on version 18 and later" — the same statement, the same table, the same pg-sprite build, runs on a newer server. That is the doc's own definition of environmental at :24: "The change is supportable, but not here, now, or as this role." A consumer that reads capability-boundary waits for a pg-sprite release; the action that actually unblocks it is upgrading PostgreSQL, and nothing in the routed class says so.

parent-index-adoption fails the other way: PostgreSQL offers no mechanism in any supported version, so capability-boundary tells a consumer to wait for something that is not coming. This one also contradicts the doc internally — the matrix table at :158 maps "T3 ❌ — no online mechanism" to by-design, and capabilities.md:148 defines ❌ as exactly "no online mechanism exists in PostgreSQL". The two tables in this document disagree about the same refusal.

2. The class cannot be assigned "at each refusal site", because two sites already emit more than one class

:64-66 states the rule as "the class is assigned at each refusal site and is never derived from the reason". The first half is right in spirit and wrong in granularity, and the code says so today:

  • admissionRefusalVerdict (pkg/migrate/verdicts.go:164) is one site. isAdmissionRefusal (:290) admits exactly three sentinels into it: ErrUnsupportedSequenceStep and ErrUnnamedIndex — both row :50, capability-boundary — and ErrIfNotExistsUnsupported, which is row :51, by-design. One site, two classes.
  • isCreateAdmissionRefusal (pkg/migrate/desired.go:341) does the same with four sentinels, routing them all through the single unsupported-statement refusal at :272. ErrIfNotExistsUnsupported and ErrDuplicateCreateName are by-design; ErrPartitionOfUnsupported and ErrUnsupportedCreateStep are not.

So the rule as written cannot be implemented as written, and — more importantly — the completeness test described at :142-147 would not notice. A test that "derives the production refusal sites and fails if a site is absent, carries the zero class, or violates the owner rule" checks that every site has a class. It cannot check that the site has the right class, and for these two sites there is no single right answer. A registry keyed on sites would go green with admissionRefusalVerdict classified capability-boundary while it silently mints a by-design refusal for every CREATE ... IF NOT EXISTS.

The repository already has the discriminator this needs. CreateShapeCause (pkg/executor/create_shape.go:18-50) is a closed nine-value enum with its own CreateShapeCauses() accessor at :53, and PartitionRefusalCause is a closed four-value enum. Keying the class on the typed cause where one exists, and the site where one does not makes the rule true, makes the registry derivable, and gives the completeness test something with correspondence in it rather than presence.

3. Seven of the nine create-shape causes are absorbed by one prose phrase, and at least one of them is misrouted by it

Row :50 ends with "a greenfield create shape that needs a future modeled route", which is where seven CreateShapeCause values land by default. if-not-exists and duplicate-name are called out separately at :51 as by-design. Of the remaining seven, concurrently does not belong with the others:

CreateShapeConcurrently refuses a concurrent build because a table born this run has no traffic to protect and a plain build cannot leave an invalid index behind a failure.

That is a permanent, reasoned decision with a named better idiom — the plain build — which is by-design by this doc's :23 definition, not a route that will be modeled later. partition-of, inherits, like and of-type all say "the absence proof does not cover it", which reads as a genuine capability boundary, so the blanket is right for those four; unsupported-kind too. multiple-operations ("the statement and operation parse boundaries disagree about its operation count") is a defensive check against a state the build should not produce, and I do not think any of the four classes describes it.

Nine values with a public accessor is a table, not a phrase. Spelling them out costs nine rows and removes the judgement call from whoever implements this.

4. Two refusals in planRefusal have no row, and no class fits them

pkg/migrate/desired.go:438 refuses a disposition "this build does not know", and :443-445 refuses when "the plan's aggregate disposition is %q but no statement carries it" — the comment above it says plainly "a report this build cannot have produced. Refuse rather than guess." Both emit unsupported-statement, so under a reason-derived map they would inherit whatever that row says; under the site-derived registry this doc proposes, they are two sites that need a class and have none.

They are not capability boundaries (nothing is missing; the input is incoherent), not environmental (the run site is fine), not by-design, and not somebody else's work. The honest routing is "this is a bug in pg-sprite, file it" — a fifth class, or an explicit statement that internal-inconsistency refusals are deliberately folded into capability-boundary with escalate as the action. Either is fine; leaving them unmentioned in a table that claims to be complete is the thing to avoid, because they are exactly the refusals an operator most needs routed correctly.


None of this changes the decision, which I think is right. The pattern across all four is one thing: the class is a property of the typed cause, and this map is drawn one level above where the causes live. Where a reason has no sub-discriminator the row is correct and I would leave it alone.

This review was generated by Claude Code (claude-opus-5).

@aparajon

aparajon commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

🤖 Comment 2 of 2 on 06a0311f — enforcement, the doc stack, and invariants. Comment 1 covers the map itself.

5. The doc links to a file that does not exist, on this branch or on main

:151 reads "The sibling capabilities contract defines the machine-readable matrix", and :161 says the two documents "must change together". docs/capabilities-contract.md is not on this branch and not on origin/main — it is added by #95, which is still a draft while this one is marked ready. The existing matrix doc is docs/capabilities.md, and it does define the vocabulary this section maps against: the T1/T2/T3 tiers at :61-63 and the ⚪/🔵/❌ legend at :144-148 all line up with the table at :154-159.

So the mapping section is correct; only its pointer is aimed at a file that has not landed. If #94 merges first, main carries a dangling relative link in a doc that the index at docs/README.md now advertises. Either land #95 first, or point this at capabilities.md and retarget it in #95 — the second is the smaller change and keeps each PR standalone.

Nothing in CI would catch it. There is no link checker in the repository, and the guards that do exist are Go tests, which this PR skipped entirely: the changes job's !docs/** filter (.github/workflows/ci.yml:43) skipped lint, build, test, and demo, so the only substantive checks that ran were DCO, Semgrep, and zizmor. That is what #96 is for, and it is also a draft. The ordering across the three is worth stating in the PR body, because right now the one that is ready is the one that depends on both of the ones that are not.

6. "The complete current reason-to-class map" is a claim, and the repo already has the pattern for enforcing it

:42-44 states the table classifies every current emission site. That is exactly the kind of claim this repo pins rather than asserts — pkg/verdict/docs_test.go:19's TestDocListsEveryRefusalReason already walks Reasons() and fails when docs/cli-output-examples.md is missing a row. Extending that test to require a | \` |row inrefusal-classes.mdtoo is three lines, and it means a twelfth reason cannot land without being classified. It would also have to be extended toCreateShapeCauses()` and the partition causes if you take up the finer keying from comment 1, which is the point: those accessors exist precisely so a doc can be checked against them.

When the registry at :142-147 gets built, one detail from the pattern it cites is worth carrying over explicitly. internal/safety/proof_types_test.go does not only derive — it also pins sentinelProofTypes (:30) with the comment "a walker that finds nothing, or that stops recognising the established shape, fails here rather than passing vacuously". A derived refusal-site registry has the same failure mode and it is the quiet one: the day someone changes how refusal verdicts are constructed, the deriver finds zero sites and the completeness test goes green on an empty set. Worth a sentence in the doc, since the doc is what the implementer will follow.

7. Invariants

docs/invariants.md has a Refusals and preflight (RF) family, and this doc does not say which entries it touches. Two are directly in scope:

  • RF-6 already draws the distinction comment 1 asks the map to draw. Its text separates "a partitioned-parent sequence is refused before its first step when it would build an index" from "or on PostgreSQL before 18 when it would add a foreign key NOT VALID" — the registry keeps the version-gated case and the not-implemented case apart in one sentence, while row :56 collapses them into one class. That is a good argument for the finer keying, and worth citing in the PR body: the entry is not changing, the doc just needs to agree with it.
  • RF-5 is what by-design encodes at the routing layer — "the dangerous literal never runs silently: risky statements with a safer native idiom get the idiom (reported) or a recommendation". :23 defines by-design as "the verdict names a safer idiom or deliberate path", which is the same rule seen from the consumer's side. Saying so makes by-design traceable to an existing invariant rather than a new coinage.

I do not think this doc establishes a new invariant — it is a contract for a field that does not exist yet, and the registry's own rule is that entries describe shipped behavior. The entry to write is in step 2 of the rollout, once the field ships and something enforces it: "every refusal carries a non-zero class, and owner is present exactly for no-online-safety-problem", enforced by the constructor and the completeness test. Worth naming that in the rollout list so the obligation is not lost between PRs.

8. Smaller points

  • :169-170 says "When the field ships, demo/tour.sh assertions are updated with the JSON surface" — demo/tour.sh is there, so that reference is good. :177-180's claim about limitations.md is also exact: limitations.md:3 opens with "pg-sprite refuses a schema change when it cannot provide its online-safety guarantees", which is precisely the sentence that stops being true for the no-online-safety-problem class. Naming the specific sentence a future PR has to change, rather than "update the docs", is the right level of detail and I would keep doing it.
  • :117 says owner is "required for every refusal in that class", and the owner-tooling row at :112 is what carries the ⚪ half of T3 — the "no online-safety problem at all" case where there is no specific other tool, just "run it directly". That works, but owner-tooling reads like a named party when it is really the absence of one. Something like direct-operator would make the ⚪ and 🔵 halves of no-online-safety-problem legible apart, which is the distinction capabilities.md:145-147 bothers to draw.
  • The create-collision row at :62 says "A target or claimed relation name is occupied at apply time", but ReasonCreateCollision's own doc comment (pkg/verdict/verdict.go:90-95) says "a relation or standalone type took it". A composite type is not a relation. environmental is still right; the row's wording just narrows the reason.

The four classes are the right four and the owner enum is the right idea. Everything above is about the map being drawn one level above the typed causes, and about the claims in it being checkable — which this repo is unusually good at and which is why the gap stands out here.

This review was generated by Claude Code (claude-opus-5).

@aparajon aparajon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Approving. The decision is right and the alternatives are recorded properly; the findings are about the map being drawn one level above the typed causes that already exist (PartitionRefusalCause, CreateShapeCause), and about the dangling capabilities-contract.md link, which lands on main if this merges ahead of #95.

This stamp was left by Claude Code (claude-opus-5).

@aparajon

aparajon commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

🤖 Re-reviewed the delta 06a0311f..be65a313 (1 file, +16/−7) on head ab8b227f. Comment 1 of 2 — the map; comment 2 covers the doc stack and the smaller points.

The four new rows are right and they are traceable, which is the part I checked first. capabilities.md already made exactly the distinction the split needs: grants, roles and RLS policies are 🔵 "provisioning / IaC" (:222), publications and subscriptions are 🔵 "replication provisioning / IaC" (:224), while views (:217), triggers (:220), extensions (:221) and DROP TABLE (:230) are ⚪ "owner tooling". Every item in rows :53 and :54 lands where the matrix already put it, and provisioning is a vocabulary the matrix had and the owner enum did not. Row by row this is correct.

1. The site the delta explains is the one where the split does not change the class

:44-48 concedes that one site covers several rows — statement.KindOther, the front door's catch-all, which cannot tell a backfill from a GRANT. That is exact: Kind (pkg/statement/statement.go:30-35) is six values with KindOther as the zero value, so the fix really is a parse-boundary change, and saying so is better than pretending the site is already fine.

But look at what that split does and does not do. All four KindOther rows (:52-55) carry the same class, no-online-safety-problem; only owner differs. So a site-keyed registry built today records the right class for that site, and the pending pkg/statement work only refines the sub-bucket.

The two sites I raised in round 1 are the ones where a site-keyed registry has no right answer, and neither is mentioned here:

  • isAdmissionRefusal (pkg/migrate/verdicts.go:290-298) still admits ErrUnsupportedSequenceStep and ErrUnnamedIndex — row :56, capability-boundary — alongside ErrIfNotExistsUnsupported, which is row :57, by-design.
  • isCreateAdmissionRefusal (pkg/migrate/desired.go:341-346) does the same with four sentinels: ErrPartitionOfUnsupported and ErrUnsupportedCreateStep on one side, ErrIfNotExistsUnsupported and ErrDuplicateCreateName on the other.

Those cross the capability-boundary / by-design line, not an owner sub-bucket, and :71's rule — "the class is assigned at each refusal site and is never derived from the reason" — is unchanged. The delta therefore explains the site where the rule survives and leaves unexplained the two where it does not. Both are already keyed on a typed sentinel, so both have the same fix as KindOther: classify at the discriminator that exists rather than at the function that dispatches on it. Two sentences under :48 would cover it.

planRefusal's two internal-inconsistency refusals (desired.go:438 and :443-445) also still have no row and no class. Same as round 1 — worth a decision either way rather than silence, since a table that says it classifies every current emission site is checkable.

2. Adding provisioning makes the ⚪/🔵 conflation in owner visible

capabilities.md:145-148 draws the line the enum is now straddling:

⚪ T3 (out of scope; no online-safety problem — run directly, or through whatever review the object warrants) · 🔵 T3 (out of scope; a different tool class owns it)

After this delta the owner enum (:114-119) holds three different kinds of thing: a genuine other tool class (provisioning, data-change-runner — both 🔵), the absence of one (owner-tooling, which is the ⚪ "run it directly" case), and pg-sprite's own other entry point (declarative-front-door). A consumer routing on owner cannot tell "hand this to your IaC pipeline" from "just run it yourself" from "use our other command" — and the first is now a real routing decision an automation would want to make, which it was not before provisioning existed.

I raised the owner-tooling naming in round 1 as a small point and it is a bigger one now: provisioning is the value that proves the enum is mixing tool-class routing with the no-owner case. Renaming owner-tooling to something like direct-operator costs one row and makes the ⚪ and 🔵 halves legible apart, which is the distinction capabilities.md bothered to draw.

The related contradiction is inside the same section. :111 calls owner "an optional structured owner field"; :124, thirteen lines later, says it is "required for every refusal in that class". Those cannot both hold, and the answer differs depending on which half of the class you are in — which is the same ⚪/🔵 question again. Comment 2 has the third document that now disagrees about it.


This review was generated by Claude Code (claude-opus-5).

@aparajon

aparajon commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

🤖 Comment 2 of 2 on 06a0311f..be65a313 — the doc stack and the smaller points. Comment 1 covers the map.

3. The retarget was right when you made it and #95 merged eleven minutes later

Round 1's finding 5 was that capabilities-contract.md did not exist and offered two ways out: land #95 first, or point at capabilities.md and retarget in #95. You took the second, which was the smaller change and the right call at be65a313 (08:55). 3f47c51f merged #95 at 09:06, and ab8b227f brought it onto this branch, so at this PR's own head docs/capabilities-contract.md is in the tree and docs/README.md:46 advertises it — including, in its own words, the "refusal-class mapping".

:158-160 now reads "a machine-readable form of the matrix, when one is decided, reuses these words rather than defining its own". It is decided, it is in the same directory, and it did define its own — capabilities-contract.md carries a Shared refusal vocabulary section with its own copy of the tier-to-class table. So the hedge is false at head, and :169's "The matrix and this contract define one vocabulary and must change together" names two documents where there are now three.

That is worth more than a link fix, because the two copies of the table are not identical:

refusal-classes.md:165 capabilities-contract.md
T3 ⚪ no-online-safety-problem (one merged row with 🔵) no-online-safety-problem
T3 🔵 (same row) no-online-safety-problem, with owner naming the tool class

The sibling attaches owner to 🔵 only. :124 here requires owner for every refusal in the class, ⚪ included. For a DROP TABLE — ⚪ per capabilities.md:230 — this document says an owner is mandatory and the sibling says the owner field is what distinguishes 🔵 from ⚪. That is the same question comment 1 raises about owner-tooling, and it now has two documents answering it differently with nothing pinning them together. Since pkg/verdict/docs_test.go is already the pattern for walking a closed set against a doc, whichever of these lands the vocabulary should be the one the other is generated from or tested against — three prose copies of one table is two too many.

Concretely for this PR: point :158-160 at capabilities-contract.md again (it exists now), say which document owns the vocabulary, and make the other one defer rather than restate. The sibling already tries to — "when that field ships, its contract owns the vocabulary and the matrix uses the same words" — so it is pointing here, and here is pointing away.

4. The example detail is now the real string, which changes what the sentence under it can claim

:105 now carries "only ALTER TABLE and CREATE INDEX statements are supported by the imperative front door", and that is verbatim what the code emits (pkg/migrate/verdicts.go:38). Replacing an invented example with the measured one is the right instinct and I would keep doing it.

It does undercut :121 though: "Prose in detail remains: it explains the concrete command or constraint to a human." The real string explains neither — it restates the front door's admission rule, and it is byte-identical for a backfill, a GRANT, a DROP TABLE, and an imperative CREATE TABLE, which are the four rows :52-55 now distinguish. That is not a defect in the change; it is the strongest argument in the document for why class and owner are needed, since today the human-readable field carries none of the routing. Saying so where the JSON sits — that detail is generic at this site today and owner is what carries the route — is better than a sentence the worked example immediately contradicts.

5. The completeness test now has a site that spans four rows

:150 describes a test that "derives the production refusal sites and fails if a site is absent, carries the zero class, or violates the owner rule". After this delta one production site legitimately corresponds to four table rows, and until the pkg/statement split lands there is no expression the deriver can evaluate to pick between them. The test can still check class (all four agree) but not owner, which is the field the split exists to populate. Worth one clause in the rollout saying the owner half of that test is gated on the parse-boundary work — otherwise step 2 promises a test that cannot be written yet.

6. Smaller point

  • Row :54 says "and other catalog work the matrix marks ⚪". That is the right way to write it — it delegates to a table that can be checked instead of freezing a list — but it is also the row most likely to go stale silently, since adding a ⚪ row to capabilities.md changes this row's meaning with no edit here. If the completeness test ever grows a matrix arm, this is the row it should cover first.

Everything above is small relative to the delta, and the routing split itself is correct. Re-approving on ab8b227f.


This review was generated by Claude Code (claude-opus-5).

@aparajon aparajon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Re-approving on ab8b227f. The four-way routing split is correct and traceable to the matrix row by row; the two comments above are refinements, not blockers.

This stamp was left by Claude Code (claude-opus-5).

A reason is not a class: unsupported-partitioned-parent spans three classes
across its four PartitionRefusalCause values, and unsupported-statement
spans four across its CreateShapeCause values and admission sentinels, so a
site-keyed map left the class ambiguous and let a completeness check pass
with the wrong answer. Two planRefusal refusals report a state this build
cannot have produced; they fit no boundary class, so they get their own
invariant-violation class rather than being folded into capability-boundary
where a consumer would wait for nothing.

The map is now a checked claim: docs tests in pkg/verdict, pkg/executor,
and pkg/preflight fail when a Reason, CreateShapeCause, or
PartitionRefusalCause exists in the code without a row here. The
capabilities contract's shared-vocabulary section is brought into agreement
so the two documents change together as both say they must.
@Kiran01bm

Copy link
Copy Markdown
Collaborator Author

🤖 Adversarial review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — pull/94, follow-up commit

All eight findings across both comments are fixed: the map is now keyed on the typed cause, a fifth class covers the internal-inconsistency refusals, and the map is pinned to the code by docs tests so a new reason or cause cannot land unclassified.

# Finding Status Explanation
C1-F1 unsupported-partitioned-parent mapped to one class while its four causes span three fixed Own table keyed on PartitionRefusalCause: the two index-build causes are capability-boundary, parent-index-adoption is by-design, parent-not-valid-foreign-key is environmental; the doc records that the matrix's 🟡 for that row is corrected in rollout step 2.
C1-F2 Class cannot be assigned "at each refusal site" when sites emit more than one class fixed Rule rewritten: the class is a property of the typed cause where one exists and of the site only where none does; the site-keyed table now lists the admission sentinels by name across their two classes.
C1-F3 Seven of nine create-shape causes absorbed by one prose phrase; concurrently misrouted fixed Own table with all nine CreateShapeCause rows: five capability-boundary, if-not-exists/duplicate-name/concurrently by-design, multiple-operations invariant-violation.
C1-F4 Two planRefusal refusals have no row and no class fits them fixed Added a fifth class, invariant-violation (report a pg-sprite bug; do not wait or retry), named after ErrInvariantViolation; folding into capability-boundary was rejected because a consumer would wait for nothing.
C2-F5 Link to capabilities-contract.md, which did not exist; ordering across #94/#95/#96 unstated fixed (in be65a31) The dead link was dropped there; #95 has since merged, so this commit restores the link and the PR body states the ordering. The Go test files added here also mean full CI runs on this PR.
C2-F6 "Complete map" is an unpinned claim; the derived registry needs a vacuous-pass sentinel fixed Docs tests in pkg/verdict, pkg/executor, and pkg/preflight (new PartitionRefusalCauses() accessor) fail on any unclassified Reason/CreateShapeCause/PartitionRefusalCause; the enforcement section now specifies the pinned sentinel keys.
C2-F7 Doc does not say which RF entries it touches or name the future invariant fixed RF-5 cited from the by-design row, RF-6 from the partition table; rollout step 2 names the invariant to add once the field ships; the doc states it establishes none itself.
C2-F8 owner-tooling reads as a named party; create-collision row narrows the reason fixed Renamed to direct-operator with the ⚪/🔵 split explained; the row now says "a relation or a standalone type". The demo/tour.sh and limitations.md confirmations need no action.

Source: #94, review comments 5609756574 and 5609757264 at head ab8b227

@Kiran01bm
Kiran01bm merged commit 61a8898 into main Sep 9, 2026
14 checks passed
Kiran01bm added a commit that referenced this pull request Sep 10, 2026
…g-first-search-path

* origin/main:
  ci: run the unit tests and their docs guards on docs-only PRs (#96)
  docs: define the refusal class routing contract (#94)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants