ADR-0075: Forms refused at the boundary, through the checks¶
Status: Accepted Date: 2026-10-17 Deciders: Alex Nodeland
Context¶
artifactr records an answer or a new artifact as given, and core may not import a schema validator. lattice RFC-0005's U1 recommended a check hook on Workspaces, which forms fill; ADR-0073 added it as Check = (workspace, command), the committing handle carrying its actor, each command checked under its own type. artifactr can't import grantr, so it can't ask who is a workspace's admin. A submission's policy (once, one a person, many) has to hold under retries and races, and a change must hold when it lands, not only when it is proposed: accepting a proposal is a command of its own, and core applies the proposed change rebased onto the head, with the reviewer's changes on top.
Decision¶
artifactr.ui.ui_checks(is_admin=None)returns a check per command type forWorkspaces(checks=...):answer_deferred,create_artifact,edit_artifact,archive_artifact,propose_changeandrespond_to_proposal. Each reads the workspace as the command's actor, and is typed against its own command.- An answer to
ask_formis checked against the schema in its request'sargs;argsthe subset doesn't hold, from a tool of the same name that didn't check them, are refused asValidationFailed. An answer to any other tool, to no request, or a second answer is left to artifactr, so its refusal (NotFound,InvalidState) is the one a client sees. - A submission is checked in the RFC's order: its form exists and the submitter may read it (
NotFound); it isn't closed or archived (FormClosed), and the version it names is the head's (FormChanged); its values fit (ValidationFailed, each error at its property); amanyform's carries its nonce and the others' none; its id is its derivation (ValidationFailedatartifact_id). A submission to an app is checked the same way, without a schema. - The two
InvalidStaterefusals say why.FormChangedandFormClosedkeep the codeinvalid_stateand addreason,"changed"or"closed", with the target'sartifact_id(and, for a change, theversionnamed and thehead) to their payload, which every surface sends as it is, throughRejection.payload(); the wire schema names the reasons (refusal_reason). Aninvalid_statewithout one, to an answer, is artifactr's: the request was answered already. - A submission's id is derived by
submission_id:sub_and 32 hex digits of SHA-256 over the parts joined by NUL, which no id holds: the policy and the form's id,["once", form_id],["per_person", form_id, acting_for(actor).participant]or["many", form_id, nonce](and an app's asmany). artifactr refuses an id in use, so a retried submit is one artifact and the policy holds without a lock (ADR-0045's pattern, and relayr ADR-0019's). The policy is a part, so a form whose policy changes takes submissions afresh, and a nonce never passes for a principal. Aper_personsubmission from whoever acts for no one, the system or an evaluator, isForbidden. Committed vectors,tests/ui/vectors/submission_ids.json, are read by portalr's tests too. - A submission is a person's alone. No one edits one, a person, an admin or an agent alike; it is never proposed; it is archived only by its author or whom
is_adminallows. - A form's
schema,submissionsandclosedchange only by its author or whomis_adminallows, and so does its archiving. The author is read from its first revision, and is the same participant, or one acting for the same person or client: the person whose agent's run made a form may close it, and every run made for them. A run that acts for no one is its own author alone.is_adminisCallable[[Workspace], Awaitable[bool]], the application's, since only it knows grantr's roles; without it only the author may. - An app is an agent's. Only an agent makes one, and a person changes its
statealone. - A proposed change is checked as the change itself when it is proposed, and again when it is accepted, as its proposer, against the head it will land on; a reviewer's own
changesaren't taken on a form, an app or a submission, which are accepted or rejected as proposed. - A submission's thread needn't be its form's. A form artifact records no thread, so a check could find it only in the log; a submission carries the thread it was sent in, which relayr's
ui:form_submittednames. - A stale edit is left to artifactr as a version conflict: a direct edit is checked against the head only when based on it; an accepted one, always.
Options considered¶
| Option | For | Against |
|---|---|---|
The hook, filled by artifactr.ui (chosen, U1) |
Core stays free of a validator and of forms; additive | A check reads before it commits, and holds nothing |
A core submit command |
One atomic decision | A validator in core, and a core command for one layer's types |
| Record anything; every consumer re-checks | Nothing new in artifactr | Every reader of the log repeats the check, and a bad value is in the log for ever |
is_admin from the application (chosen) |
No dependency on grantr; any application's notion of an admin | The application writes one function |
| Reasons in the payload (chosen) | A client tells "changed" from "closed" without reading prose | Two subclasses of InvalidState |
| A code of their own | As plain | A code artifactr's surfaces and their documents don't list |
Consequences¶
- Easier: a bad answer or submission never reaches the log, and portalr shows each refusal under its field, and "This form changed" or "Closed" from its reason.
- Harder: a check is not a lock. A form closed between the check and the commit takes one late submission; whoever reads submissions keeps the form's
closedin mind. - Harder: an application with checks of its own for the same command types combines them in one.