schemachange: type the shadow refusal causes and add the Proof view - #124
Conversation
- Every shadow-operation refusal is a *RefusalError carrying one
RefusalCause from a closed set (RefusalCauses()), wrapping
ErrInvariantViolation and the errors behind it, so errors.Is against
the sentinel keeps working and an importer branches on
RefusalCauseOf(err) instead of matching message text. Each cause
names its invariant (LK-1, ST-5, ST-6, ST-7).
- refuse() replaces every fmt.Errorf("%w …", ErrInvariantViolation)
site in build, drop, inspect, lock, identity, grants and fidelity.
- BuiltShadow.Proof() is the exported, JSON-encodable view of the
proof; BuiltShadow marshals as it. There is deliberately no
UnmarshalJSON: a checkpoint decodes into a Proof and compares it with
the Proof of what InspectShadow returned, and only the builder and
the inspection mint a BuiltShadow. The nested fidelity, grant, policy
and identity types gain snake_case JSON tags.
- docs/refusal-classes.md classifies every cause; docs_test.go in the
package fails on a cause without a row, and on a constant
RefusalCauses() does not enumerate. The design doc's package row
names Proof and RefusalCause.
- Tests: every existing ErrInvariantViolation assertion also asserts
the cause; a caller's own cancellation yields no cause; the
RefusalError rendering and RefusalCauseOf through wrapping; a
BuiltShadow round-trips through JSON into the Proof InspectShadow
re-derives, on a table with an identity column, storage parameters,
a grant and a policy, with the top-level key set pinned.
- The checkpoint round-trip test walks the encoded Proof and compares the full set of key paths, at every depth and through each array element, against the pinned list. Marshal and Unmarshal agree by Go field name when a tag is missing, so the equality of the two proofs could not notice a lost nested tag; the walk does. - The fixture table also carries a column grant and a NOT VALID check, so every nested fidelity type is present in the encoding, and the test requires each nested slice to be non-empty before walking. - The test builds its fixture with newShadowFixtureWithRole instead of repeating its statements.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 1/2 — adversarial correctness. Reviewed 0 blocking, 4 non-blocking. Every cause's Non-blocking1. The completeness guard fails open for the one declaration shape it exists to catch.
ident, ok := vs.Type.(*ast.Ident)
if !ok || ident.Name != typeName {
continue
}A constant declared in the same block without repeating the type has That matters here beyond the usual coverage argument, because this PR extends the claim
It can. There is a second-order effect too: an unclassified cause gets This is inherited rather than introduced: Mutation that survives on
|
|
🤖 2/2 — the two lenses. OSS adoption, and integration ease for Lens 1 — OSS adoptionThis is the shape a library should expose, and it is the right half of the problem to have solved first. An external adopter gets a closed set they can enumerate ( Declining to add Two things an external adopter would still trip on. The library now has two different sentinels both spelled
Lens 2 — integration ease for importersFor an importer this is a clear improvement over the status quo, and it lands on the right side of the line: the fix is in the engine, not re-derived in the consumer. Before, an importer wanting to re-acquire a lock on Three things that would finish the job. The lock's own failures stayed untyped, and they are the ones an importer meets first. The structured error carries no structured identifiers. The stated motivation is that the message text "carries schema, role, sequence, and backend names an importer must not render verbatim" ( Nothing pins the This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving. The typed cause is correct at all eleven refusal sites — each cause's Invariant() matches the // INV: comment where it is minted — the sentinel and every wrapped error still answer errors.Is, and the Proof's wire shape is pinned by a guard I confirmed bites. All four findings in my review are non-blocking: a completeness-guard hole inherited from pkg/preflight, an unpinned encoding one level below the Proof, two causes without an assertion, and a doc sentence that overstates an iff.
This stamp was left by Claude Code (claude-opus-5).
pkg/schemachange's shadow operations now refuse with a typed*RefusalErrorcarrying oneRefusalCausefrom a closed set, andBuiltShadowgains an exportedProofview that a checkpoint can encode and a resume can decode and compare.Follow-up to #121 (items C2-2 and C2-3 of the adoption/integration review, deferred there), which is on
main.Why
BuildShadow,DropShadow, andInspectShadowrefused with a barefmt.Errorf("%w: LK-1: …", ErrInvariantViolation), so every ST-5, ST-6, ST-7, and LK-1 refusal collapsed into oneerrors.Istarget. An importer that needs to react differently — re-acquire the lock, leave a relation the engine did not build to an operator, drop and rebuild, or report a bug — had only the message text to branch on, and that text carries schema, role, sequence, and backend names it must not render verbatim.BuiltShadowhad ten accessors and no encoding, so persisting the proof for a checkpoint was hand-rolled per importer, and the "compare with your checkpoint" stepInspectShadowexists for had no defined shape to compare.What
refusal.go:RefusalCausewith eleven constants (shadow-lock-unproven,-lock-lost,-lock-held-elsewhere,-lock-unconfirmed,-proof-empty,-source-shape,-statement-target,-owner-mismatch,-foreign-relation,-grants-differ,-identity-handoff),RefusalCauses(),(RefusalCause).Invariant(),RefusalError{Cause, Detail}whoseUnwrap() []errorexposesErrInvariantViolationfirst and then the errors behind the refusal (the lock session's loss, the interrupted statement), andRefusalCauseOf(err). Every former sentinel site inshadow.go,lock.go,inspect.go,identity.go,grants.go, andfidelity.gogoes throughrefuse(...);rg ErrInvariantViolation pkg/schemachangefinds only the sentinel andrefusal.go.proof.go:Proofstruct with snake_case JSON tags mirroring the accessors;BuiltShadow.Proof();BuiltShadow.MarshalJSON()encodes theProof. NoUnmarshalJSONonBuiltShadow: the constructor stays private, so the copier and cutover keep accepting only a value the builder or the inspection returned.IdentityColumn,SequenceOptions,FidelitySnapshot,Grant,ColumnGrant,Policy, andUnvalidatedConstraintgain snake_case JSON tags so the nested encoding is stable rather than Go-field-name-shaped.docs/refusal-classes.md: a "Shadow operation refusals, keyed onRefusalCause" table classifying each cause;docs/copy-and-swap-design.mdpackage row namesProofandRefusalCause.docs_test.go(new, packageschemachange_test) fails when a cause has no doc row and when aRefusalCauseconstant is not enumerated byRefusalCauses(), the same guardpkg/preflighthas forCopySwapRefusalCause.ErrInvariantViolationassertion inlock_,drop_,inspect_,shadow_, andshadow_owner_integration_test.goandshadow_test.goalso asserts the cause (RefusalCauseOf); the caller-cancellation test asserts an empty cause;refusal_test.gopinsInvariant(),RefusalCauseOfthrough wrapping, and theError()rendering (the one renderer test the log-surface rule allows);proof_integration_test.gobuilds a shadow over a table with aGENERATED ALWAYS AS IDENTITYkey,fillfactor, a tableGRANT, a columnGRANT, aPOLICY, and aNOT VALIDcheck, assertsProof()equals the accessors field by field, round-tripsjson.Marshal(built)into aProofequal toInspectShadow(...).Proof(), and pins the encoding's key paths at every depth (a walk of the decoded tree against a hand-written list, since encoder and decoder agree by Go field name when a nested tag is missing and proof equality alone would not notice). Run locally against PostgreSQL 16; CI covers 14 → 18.Before / after
Decisions a reviewer may want to veto
docs/refusal-classes.md:invariant-violationonly forshadow-lock-unproven,shadow-proof-empty,shadow-statement-target(caller-side incoherence) andshadow-grants-differ(the engine's own write did not take); the seven lock, owner, shape, foreign-relation, and identity-handoff causes areenvironmental. All still wrapErrInvariantViolation; the sentinel is the stop mechanism, the class is the consumer's route.shadow-identity-handoffis one cause for both the build-time (statement replaced the column) and inspect-time (shadow altered since) detections; the detail text distinguishes them.Proofslices are not normalised: a shadow with no identity columns encodesidentity_columns: null, and decodes back tonil, so the round-trip is faithful andassert.Equalon the two proofs holds without a normaliser.References
docs/refusal-classes.md§ Shadow operation refusals;docs/invariants.md§ LK-1, ST-5, ST-6, ST-7;pkg/preflightCopySwapRefusalCausefor the house pattern.🤖 Drafted with Amp (Claude Opus 4.6); reviewed and edited by the author.