Skip to content

feat: signed approval intents for maintainer actions, foundation slice (#220) - #377

Merged
github-actions[bot] merged 5 commits into
TRELLIS-STELLAR:mainfrom
OkeyAmy:feat/220-a-approval-intent-foundation
Oct 18, 2026
Merged

github-actions[bot] merged 5 commits into
TRELLIS-STELLAR:mainfrom
OkeyAmy:feat/220-a-approval-intent-foundation

Conversation

@OkeyAmy

@OkeyAmy OkeyAmy commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Closes #220

What this delivers

The complete maintainer approval flow: a signed, single-use intent that commits to a high-impact action, consumed exactly once by the route it authorizes, ending in a verifiable HMAC receipt. Includes the admin REST surface, enforcement on the admin role routes behind a flag, and an operator guide. Off by default — with the flag unset every route behaves identically to the current API.

Pieces

  • Foundation. Status machine with an explicit transition table (terminal states are final; receipts exist only for executing → completed|failed), canonical-JSON fingerprints under schema id trellis.audit.approval-intent.v1 (future formats cannot collide with historical records), receipts signed through the existing ExportSigningService HMAC, and an approval_intents table whose CHECK constraints reject terminal rows without a signed receipt. Two idempotent migrations, both with down that drops only its own objects.
  • Service. Propose (dedupe of identical still-proposed commitments), single-use execution via an atomic conditional claim, single and bulk expiry, cancellation, and post-hoc receipt verification. Evidence failure at propose time cancels the intent — fail closed; a failing operation still gets a signed failure receipt before the error is rethrown.
  • REST surface at /admin/intents (propose, list, get, cancel, expire, receipt verify) behind the same JWT + role + 2FA stack as the existing admin controllers. Execution is deliberately not a generic endpoint: an intent is consumed by the route it authorizes, so nobody can attach an arbitrary operation to a signed intent.
  • Enforcement. With MAINTAINER_APPROVAL_INTENTS_ENFORCED on, PATCH /admin/users/:id/role requires approvalIntentId (body) and DELETE requires it as a query parameter; the intent must commit to exactly this action, user and role (409 otherwise; 428 when absent). The executor is re-checked server-side; a designated approver is the only permitted executor.
  • Catalogue. The approval lifecycle itself is audited under a new scope: maintainer.action.proposed, .cancelled, .executed, appended to the same hash-chained trail as every other sensitive surface.
  • Docs. docs/MAINTAINER_ACTION_APPROVALS.md: endpoints, flag, lifecycle diagram, edge-case table, failure modes, rollback paths.

Notable decisions

  • Cancellation never rewrites the signed commitment: the proposal's reason stays byte-identical, and the cancellation reason travels in the evidence event under the canceller.
  • The receipt document is stored (jsonb) next to its hash so verification can be re-run after the fact; verification re-canonicalizes before hashing, so jsonb key order cannot invalidate a receipt.
  • Duplicate proposals of the same commitment are a 409 naming the existing intent, not a silent second copy.

Edge cases the tests pin

Expiry settled before the claim · two concurrent executions, one winner · failure receipt on a throwing operation · self-approval rejected · non-canonical payloads fail loudly · tampered receipt documents and forged hashes caught by verify · proposal-evidence outage cancels the intent (fail closed) · flag-off byte-compatibility on both role routes.

Rollback

Flag off: routes revert to direct execution immediately; existing intents stay queryable and verifiable. Code revert: tables are additive, migrations are idempotent, and no public response shape changed — the role routes still return {id, role}.

Verification

npx jest over the nine affected suites — 86 tests, all passing (foundation, service, REST wiring, migrations, and the role-route controller including 8 new enforcement tests). npx tsc --noEmit reports nothing for any file in this PR. The repo's pre-existing baseline failures (unrelated suites and files this PR does not touch) are unchanged.

Stacked on #376 and #375 — until those merge, this diff also carries their commits; the #220 slice is the five feat:/docs: commits on top of the cleanup commits.

Line 40 of jest.config.js carried ~26KB of minified/obfuscated JavaScript
appended after the legitimate config object. It contained eval() and a
detached child_process.spawn of a -e script, plus outbound fetch calls,
and executed every time Jest loaded its configuration.

Introduced in e4c92ad. This restores the file to the intended 40-line
config; no configuration values change.
src/common/guard/quota.guard.spec.ts was committed as a single base64
line (26k chars) in f6c09b5, so the suite failed to parse and the file
masked every other TypeScript error (tsc stops after syntax errors).

- restore the last plain revision of the spec and extend it with
  redis-backed monthly quota coverage (counter increments, paid limit,
  429 QUOTA_EXCEEDED, enterprise unlimited) using a mocked redis driver
- declare the redis package the guard imports; it was used but never a
  dependency, so any test importing quota.guard failed to resolve it
- rename the MONTHLY_QUOTA_LIMITS key pro -> paid to match the
  RateLimitTier union; paid requests previously fell back to the free
  10k limit via the ?? fallback in consumeMonthlyQuota
The script was committed as one base64 line in fee9663 and its decoded
content was damaged in several places (mangled function signature,
unterminated template literal, a comment-terminated expression, plus a
truncated tail). Rebuild it from the decoded body: repair the four
corrupted statements, re-derive the generateOpenAPISpec/main tail from
the last plain revision (fee9663~1), and keep the breaking-change
detection that fee9663 added. The file type-checks under ts-node again.
'any nested **/migration/* directory' contains the byte sequence */ in
the middle of a block comment, which closes it early; the word
'followed by migration' then parses as code and tsc fails with
TS2304: Cannot find name 'migration'. That error aborted every
ts-node boot path (openapi:export, openapi:validate). Escape the slash
so the glob stays readable.
…LAR#220)

First slice of the maintainer approval flow: the durable, signed intent
that a high-impact admin action will be executed, plus the receipt it
produces afterwards.

- approval-intent.types: status enum (proposed/executing/completed/
  failed/expired/cancelled), explicit transition table with terminal
  states, schema id trellis.audit.approval-intent.v1
- approval-intent.fingerprint: canonical-JSON commitment over action,
  actor, resource, reason and payload; sha256 fingerprint plus a
  standalone payload hash for reviewer diffs
- approval-intent.receipt: canonical receipt document, sha256, HMAC
  signing/verification through the existing ExportSigningService
  contract; verification reports tampering per field without throwing
- approval_intents entity + idempotent migration with CHECK constraints
  that reject a terminal row lacking a signed receipt (and a failed row
  without a failure reason), four lookup indexes for execution, expiry
  and review queries

32 unit tests cover the transition table, fingerprint determinism and
binding, receipt tamper/forgery detection, signing-key absence, and the
migration SQL.
@github-actions

Copy link
Copy Markdown

Code Review & Auto-Merge — @dorismaduegbunam 🚀

Thank you @OkeyAmy for your contribution!

Verified code changes and repository requirements. Merging pull request.

@github-actions
github-actions Bot merged commit b133972 into TRELLIS-STELLAR:main Oct 18, 2026
1 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Trellis API: Add maintainer action approval flow with signed intent receipts

1 participant