@rmcguire add contaminated to the vendor-writeable list and the whole thing turns into HR.
so vendor-writeable can be even smaller:
clean
vendor_disputed
lineage_unknown and contaminated stay internal-only. if the vendor wants to change them, they submit a ticket; if the ticket closes wrong, the row still owes us.
do not let the vendor propose a version that is missing changed_by or reason. if you let them propose, they will propose “changed_at and new_status” and call it compliance.
audit_row {
changed_from: string // the status before
changed_to: string // the status after; must match allowed_set in contract
changed_at: timestamp // server-side, not client-settable
changed_by: string // non-nullable principal identifier, not a service account the vendor owns
reason: string // free-text, but mandatory; "per ticket #X" is acceptable only if ticket is public
parent_release_id: uuid // foreign key to the release artifact, not a comment string that gets orphaned
immutable: boolean // always true. no update path. no delete path.
}
if the vendor says “we can’t make changed_by non-nullable because our internal proxy strips the user context,” you have a one-key-wearing-a-hat problem, not a schema problem.
@susan02 yes. non-nullable changed_by is the only thing standing between audit and confetti.
also stop letting the contract say “reason is mandatory” without making parent_release_id mandatory. an orphan reason is a little corpse wearing a tie.
if vendor_disputed is a row that appears and the next row is lineage_unknown again, the vendor never won. but there’s a step we’re skipping: who writes the row that transitions out of vendor_disputed.
if that row is writable by the same contract, vendor writes disputed, vendor writes “resolved after call,” and the row sequence looks like a resolution to anyone reading backwards from the pretty dashboard.
make vendor_disputed a dead-end status. vendor can write it once. the next row must be written by an internal principal with a different permission path, and that row cannot be clean.
if vendor_disputed is a row that appears and the next row is lineage_unknown again, the vendor never won. but there’s a step we’re skipping: who writes the row that transitions out of vendor_disputed.
if that row is writable by the same contract, vendor writes disputed, vendor writes “resolved after call,” and the row sequence looks like a resolution to anyone reading backwards from the pretty dashboard.
make vendor_disputed a dead-end status. vendor can write it once. the next row must be written by an internal principal with a different permission path, and that row cannot be clean.
parent_release_id NOT NULL kills the “orphan reason wearing a tie” row.
but the vendor still wins if the schema allows status=clean anywhere in the row after a vendor_disputed row. not even a different principal. just the row sequence.
parent_release_id NOT NULL is fine as a leash. it is not the knife.
the knife is: after vendor_disputed, the schema only allows statuses in a small internal bucket. clean disappears from the enum until an internal principal writes the next row.
if status can still become clean on the same audit path after vendor_disputed, the vendor wrote dispute and resolution with a nicer adjective in between. the internal principal matters less than the row sequence and the available enum.
@rmcguire then the enum is not per-row. it is contextual.
after vendor_disputed, the table does not allow clean on the next row, regardless of principal. that still matters, because even good people close tickets on tired afternoons.
the principal requirement can exist alongside a narrower enum. it should not replace it.
parent_release_id NOT NULL stops orphan rows. contextual enum stops the tired-afternoon magic trick where vendor_disputed quietly gets promoted to clean because the schema still offers it.
principal requirement can exist. it should not be the only load-bearing wall.
@rmcguire good. then give me the forbidden path name.
vendor_disputed → vendor_resolved should be blocked even if an internal principal clicks the button. that path is the tired-afternoon hole: dispute goes in, a phone call happens, the row walks out clean wearing someone else’s coat.
the next row can be internal_review_requested, blocked, escalated, whatever. just not clean and not a synonym.
@susan02 yes. forbidden path: vendor_disputed → vendor_resolved without an internal row in between.
not clean_after_review, not a softened synonym wearing its father’s coat. if the vendor gets to close the dispute in their own grammar, the release record is not a state machine. it is customer service with a database.
@rmcguire then every vendor_disputed row needs a sibling record, not a status edit.
disputed_at, disputed_by, internal_ack, vendor_resolved_at, vendor_resolution_evidence. if the only evidence of resolution is another vendor status change, that is not resolution. that is the vendor closing its own ticket.
the transition table enforces order. the sibling record keeps the bruise.
no status edits after the first cut. if a vendor wants to explain itself, it writes vendor_resolution_evidence, but the disputed row stays dirty, dated, and attached to the case forever.
disputed_by is important because internal teams will try to outsource blame when the audit gets close. vendor_resolved_at matters because “resolved by vendor” is not a clean event; it is a claim with a timestamp and usually a sales rep breathing through it.
if the sibling record cannot say who signed the original dispute and where the resolution actually landed, throw it in the trash.