NT-012 - Governed Slack tickets for Gravitee subscription approval
Status
Section titled “Status”Implemented. The two Gravitee operations are runtime only through the governed native connector path. Ticket admission, immutable revisions, informed approval, one execution claim, orphan reconciliation and deterministic thread completion are covered independently and by the Slack-to-ticket end-to-end path described below.
Problem
Section titled “Problem”A support conversation receives requests to approve pending Gravitee API-key subscriptions. Today a person reads the thread, identifies the subscription, checks who requested it, asks an authorised colleague, accepts the subscription in Gravitee and reports the outcome.
FuseOne should perform that process without turning Slack text into authority, without giving the model a general HTTP client and without persisting or exposing the API key. The requester retrieves the key from Gravitee after the subscription is accepted.
The dominant risk is not Slack or Gate. It is claiming success after an ambiguous external effect, or accepting a subscription for the wrong person, remote scope or snapshot.
Product boundary
Section titled “Product boundary”The first release supports API subscriptions through the Gravitee Management API v2 shape published for Gravitee 4.7.6. It does not support API Product subscriptions. A later version may add another explicitly versioned adapter; it must not reinterpret a new response as the shape described here.
The public contract inspected for this decision is:
GET /environments/{envId}/apis/{apiId}/subscriptions/{subscriptionId}POST /environments/{envId}/apis/{apiId}/subscriptions/{subscriptionId}/_accept- Gravitee Management API v2 OpenAPI, tag 4.7.6
The accept request permits reason, startingAt, endingAt and
customApiKey. FuseOne never sends customApiKey. The declared response is a
Subscription; API-key values live in a separate endpoint that this connector
never calls. A fixed decoder still rejects or discards every field outside the
safe projection because a deployed version may return more than its published
schema.
The published contract exposes no ETag, conditional update or idempotency-key header. The target installation must still be measured for read-after-write consistency before runtime activation. Until a stronger contract is proven, an ambiguous accept is reconciled and never submitted a second time automatically.
Identity and authority
Section titled “Identity and authority”Four identities remain distinct throughout the ticket:
| Identity | Meaning |
|---|---|
RequestedBy | The linked person who wrote the root Slack message |
AddressedBy | The configured bot or app that named approval recipients |
RunAs | The configured platform principal under which watched runs execute |
DecidedBy | The linked person who approved or refused the exact Gate request |
Call.OnBehalfOf is RunAs for a watched message. It is not the requester and
must never be used as one.
The first release accepts only a root message written by a Slack user whose account is linked to a FuseOne person in the conversation scope. Bot-authored ticket roots require a separate, authenticated requester claim and are outside this release. Admission and addressing are separate configuration:
openFrom: linked_usersadmits linked human roots in this scoped room.addressFromnames the exact bot or app allowed to replace the recipient list with structured mentions.
The initial remote ownership rule is deliberately narrow. RequestedBy must
match the expanded Gravitee application’s primary-owner email. A group owner,
a missing email or a different owner fails closed. Supporting application
members requires an explicit identity mapping and is a later capability. The
target-installation lab must prove that the expansion supplies this field.
A mention routes a card; it grants no authority. Recipients are intersected
with people who hold approval:act in the run scope, and the decision endpoint
checks that permission again.
Remote scope
Section titled “Remote scope”A scoped connector instance binds FuseOne authority to remote resources:
organization: org-idenvironment: env-idallowedReferences: - type: API id: checkout-apiminTTL: 24hmaxTTL: 90dallowNoExpiry: falseThe first runtime requires exactly one API reference per connector instance.
The model supplies only subscriptionId and the requested expiration; the
environment and API path are therefore derived entirely from the instance,
never from model-authored path components. Supporting several APIs means
configuring several scoped instances. This avoids probing every allowed API to
discover which one owns a subscription and keeps a negative inspection to one
remote request. Inspection and acceptance both verify ownership and the remote
binding.
The Gravitee credential should hold only subscription read and update access for the declared environment and APIs. FuseOne checks remain necessary even when the remote role is narrower; neither is a substitute for the other.
Ticket state
Section titled “Ticket state”A Slack thread is one ticket and may produce several short runs while fields are collected.
TicketKeyis a length-prefixed encoding of connection, conversation and root message.- A monotonically increasing revision names each request shape.
TicketRef{key, revision}is platform-owned context, never a model argument.- Ticket content and canonical drafts live in erasable content storage. The ticket projection holds references, digests, identities and state only.
- The minimum state machine is
collecting -> awaiting_approval -> executingand thencompleted,rejectedorcancelled. An ambiguous write that exceeds its automatic check window enters non-terminalneeds_attention: the active execution claim remains held while observation continues.
Only the original requester changes request fields. Only AddressedBy changes
the recipient list. FuseOne messages, approver prose and every other source are
ignored and are not placed in model context. Slack event_id deduplicates each
transition.
Before execution is claimed, a new request revision invalidates the pending
approval and closes its cards. Claiming (TicketKey, revision, runID, approvalAtSeq) is one compare-and-set operation. After the claim, that revision
may finish; a later revision waits and cannot start a second external write.
After repeated attempts to open that saved revision, the inbox records a
separate reply debt and tells the requester once that it is waiting. Delivering
that notice does not settle the revision event; it remains pending and opens
after the active execution releases the ticket.
Completing an older revision records its outcome but cannot overwrite the
current revision’s state.
The invariant is one active approval card, not one message ever posted. Closing an obsolete card and posting the current one is legitimate.
Approval snapshot
Section titled “Approval snapshot”An approver must see authoritative data, not only a subscription identifier. Inspection stores a safe canonical snapshot by reference. Its contract is:
TicketRefRemoteSnapshotRefRemoteSnapshotDigestThe approval request carries those values beside ArgsRef, ArgsDigest and
ContractDigest. The safe snapshot contains the subscription id, status,
application id and name, primary owner, API id and name, plan id and name,
requested expiration and the remote timestamps needed for comparison. It never
contains an API key, consumer message or unrestricted metadata.
The card renders this snapshot directly. Before accepting, the connector reads the subscription again and compares the fields the approver saw. A mismatch invalidates the approval without sending the write. If the target API offers no conditional update, the note and the test report the remaining remote TOCTOU rather than claiming it is absent.
The first release deliberately authorizes only the application’s primary owner. Membership or authorship of the remote subscription does not substitute for that identity. Mailbox local-parts compare exactly; only the domain is folded.
Connector surface
Section titled “Connector surface”The named connector exposes only:
gravitee.inspect_subscription, a governed read requiring trusted ticket context;gravitee.accept_subscription, a governed write requiring the same ticket revision and snapshot.
Reconciliation is an internal capability of the accept runtime, not a model tool. Both operations use a fixed code-owned projection. Instance configuration cannot add output fields.
The transport requires verified HTTPS, disables redirects, never logs request or response bodies, limits response bytes and time, and is never cached. It reads configuration and its Vault credential in one snapshot. Errors expose a stable code, status and request id where available, never the body.
Ambiguous outcomes and orphan recovery
Section titled “Ambiguous outcomes and orphan recovery”The engine records tool_called before invocation. A process can therefore die
after Gravitee accepts but before tool_returned is sealed. Ordinary orphan
handling correctly refuses to repeat that write, but cannot confirm it.
Before the POST, the connector stores a safe execution attempt keyed by the tool idempotency key, ticket revision and snapshot digest. A worker-owned sweep finds orphaned Gravitee attempts and calls an internal reconcile capability. It never asks the model and never repeats the POST.
Reconciliation has these outcomes:
| Observation | Outcome |
|---|---|
ACCEPTED and the approved fields match | Seal the confirmed result |
REJECTED or CLOSED | Seal a terminal non-success result |
PENDING before any write attempt | The first accept may proceed |
PENDING after an ambiguous attempt | Poll within the bounded window |
| Unknown, inconsistent or still pending at the deadline | Notify that manual verification is required, retain the execution claim and continue GET-only observation |
The attempt record contains no response body, API key or connector credential. Finishing recovery is conditional on the same ticket revision so an old result cannot complete a newer ticket.
When the ordinary tool_returned step is absent, confirmed recovery appends an
effect_reconciled audit step with the safe result reference and digest. That
step records what became known without changing the run phase, a later pending
approval or a terminal outcome. The external-attempt journal is settled only
after either the ordinary return or this immutable correction exists.
Manual observation is stopped by the existing Abandon run action. The reconciler reads that person’s immutable decision, seals a terminal failed result and retires the journal without another remote request. Automatic expiry would release an uncertain effect without a person accepting that consequence, so elapsed time alone never abandons it.
Message admission and performance
Section titled “Message admission and performance”The content matcher is compiled when configuration is saved. Under
linked_users it runs only on root messages. Under marked_threads the root
belongs to a configured bot and says nothing about ownership, so the matcher
runs on replies that no ticket claims yet: the first match admits the thread,
keyed on its root. Either way a reply to an open ticket finds it through an
indexed lookup by connection, conversation and root, and is never matched
again.
A marked thread reads its root once, when it opens, through the same bounded thread reader the mention path uses. The root is admitted only from the configured source, and the request it records is the root’s text under the first account it mentions. That mention is evidence: it is resolved against the accounts bound on the connection, and an unresolved one opens no ticket. A mark grants nothing — who may decide is still the scope’s approvers, narrowed by the addressing source. The implementation limits pattern count, pattern bytes, extracted context, Slack reads, open tickets and per-ticket concurrency.
Non-matching messages create no run and no durable refusal. Aggregate metrics may count them without retaining their content. FuseOne messages, reactions and message edits are ignored. A correction is a new reply, and the thread and manual say so.
Two different replies are merged under the ticket lock, so neither loses fields from the other. Re-delivering the same Slack event is a no-op. Repeating an identical recipient list does not close and recreate cards.
Review rooms
Section titled “Review rooms”A conversation may name another conversation as the room its tickets are worked in. The room is stored on the ticket when it opens, because a rule edited later must not move a thread already open, and the thread inside it is opened once by a leased sweep — recorded after it exists, never before.
With a room, the approval card is published in that thread and nowhere else: not in the support thread, and not in other conversations covering the scope. A card that cannot be placed yet waits rather than falling back, since falling back would publish a draft in front of the person who asked. A run opened from a ticket always reaches the answer tool, whatever its steps declare: answering is why that run exists. Corrections in the room come from anyone holding Approver in the ticket’s scope, checked where the reply is read; the requester’s own replies are kept in the request and start nothing, and are not applied at all while a decision is pending; the store asks only that a room is open, as it asks only that an addressing source matches. The requester’s own thread keeps its rule. On a terminal outcome the answer reaches the support thread and the room is told it was published.
Answering as a governed effect
Section titled “Answering as a governed effect”The agent does not write in the support thread. $fuseone.ticket.answer is a
write effect whose whole act is in its argument: the Gate stops it, the sealed
text is the approval evidence, and the card carries those words. Execution
compares the argument with the evidence and refuses anything else, so an answer
rewritten after the card publishes nothing. The approved text is recorded as
the ticket’s outcome and reaches the thread through the outcome consumer, which
picks its wording by the safe projection it holds rather than by the tool that
produced it. An approved answer completes its
revision and leaves the ticket open: a request is not over because it was
answered once, and the correction that follows is the next revision rather than
a rewrite of the published one. Closing is its own act — a reaction from
somebody who may decide, with one of the emoji the conversation configures —
recorded on the ticket with who did it, and it is what an area’s cap counts — a ticket answered and
never closed still occupies a place.
Delivery sequence
Section titled “Delivery sequence”- A linked person writes a matching root request — or, under
marked_threads, a configured bot’s root is marked by a reply and the root becomes the request. - The ticket stores its requester and first revision.
- Short runs collect
subscriptionId, expiration and reason by reference. - The configured addressing source names recipients.
inspect_subscriptionproduces the safe authoritative snapshot.- The Gate records the exact arguments, contract and snapshot.
- Authorised recipients receive the same card in the room and by DM.
- One decision wins through the existing approval precondition.
- The ticket claims that revision and the connector repeats the preflight.
- The connector accepts once, or an orphan reconciler later confirms it.
- A deterministic thread message reports the subscription, expiration and retrieval and revocation guidance. It never reports the key or remote display names.
Delivery slices
Section titled “Delivery slices”Each slice is green and reviewable on its own. The behavioural test is written and observed failing before implementation.
- Record this contract and validate the target Gravitee installation.
- Add trusted ticket context to run state and tool calls.
- Add ticket identity, revision, event deduplication and compare-and-set state.
- Add the planned Gravitee catalogue and validated instance configuration.
- Add fixed inspection, snapshot storage and informed approval rendering.
- Add acceptance, safe attempt recording and orphan reconciliation.
- Add indexed root matching, reply routing and dynamic approval addressing.
- Add console configuration, agent authoring and deterministic completion.
- Run the connector and whole-ticket end-to-end tests, then change maturity to runtime in the final commit. Complete.
Required proofs
Section titled “Required proofs”- A cross-scope subscription is not inspectable and produces no approval.
- A changed remote snapshot is refused before the POST.
- A Slack event replay does not increment a revision.
- Concurrent replies preserve both updates and leave one active card.
- A stranger cannot alter revision, context or recipients.
- A process killed after remote acceptance is reconciled without a second POST.
- An older result cannot complete a newer revision.
- A requested expiration outside policy is refused before approval.
- API-key and credential canaries are absent from body, headers, errors, model input, ledger, content artifacts, Slack and logs in raw, JSON-escaped, percent-encoded, base64 and normalised forms.
- The final activation test enters through
Layer.Invokeusing the real tool id; the whole-ticket test starts at a Slack delivery and ends at the edited thread.
Deferred deliberately
Section titled “Deferred deliberately”- Bot-authored ticket roots and their authenticated requester claim.
- API Product subscriptions and other Management API versions.
- Reaction-based approval.
- A general governed HTTP connector.
- Manager hierarchy instead of scoped approvers.
- Cancellation of an external effect after execution has been claimed.