Skip to content

refactor(server): give routes three shapes and hand handlers their inputs - #6941

Open
otavio wants to merge 7 commits into
masterfrom
refactor/pure-handlers
Open

refactor(server): give routes three shapes and hand handlers their inputs#6941
otavio wants to merge 7 commits into
masterfrom
refactor/pure-handlers

Conversation

@otavio

@otavio otavio commented Aug 24, 2026

Copy link
Copy Markdown
Member

What

Route registration owns the HTTP ceremony. A route registers under one of three shapes and the
wrapper binds the request, normalizes the paginator and sorter, validates, resolves the namespace
scope and the actor, calls the handler and encodes the result. A handler becomes a function of its
inputs and no longer knows that HTTP exists.

Three routes convert as worked examples. The other 82 keep the existing adapter, which stays until
the conversion.

Why

Closes #6940. Implements ADRs 0001 through 0005.

Eighty-five handlers each rewrote the same five blocks, and the copies had drifted. X-Total-Count
is written before the error check in two places and after it in ten, and two sites report the length
of the page rather than the count the service returned. Three idioms answered "which namespace is
this bounded to?", so a cross-tenant read could be introduced in any handler and reviewing for it
meant reading all of them.

Changes

  • server/api/pkg/gateway: One, List and None, each producing an echo.HandlerFunc so
    per-route middleware composes exactly as before. List writes the total count after checking the
    error, from the count the handler returned. The wrapper refuses a request when the gateway context
    is not installed, and keeps stashing that context in the request context, which is how the service
    layer's tenant, username and identity lookups still reach it.
  • Declaring an exception: Unbounded(reason) and Anonymous(reason) both take a required
    reason. The two are independent — a device authenticating with its own token is bounded to a
    namespace and still carries no actor. Each registration records a Declaration, which is what
    lets a test refuse a claim whose reason is empty; Echo does not expose a route's handler, so there
    is nothing else to enumerate.
  • IdentityFrom / Identity.Actor / IdentityHeaders: the authenticator stamps seven headers
    through Identity.WriteTo, and everything downstream took them apart one accessor at a time.
    IdentityFrom is the read side of that write, Actor narrows the result to what a handler may
    see, and IdentityHeaders names the set for anything replaying a request internally. A
    round-trip test holds the write and the read to the same headers.
  • gateway.Actor: the authenticated identity, which is not always a person. An API key and a
    device token name a namespace principal with no user behind them, so requiring a user ID would
    refuse every API-key and MCP caller. ADR 0004's wording implies user; CONTEXT.md does not, and
    the code follows CONTEXT.md.
  • MCP dispatch: /mcp replays the caller's headers against the router, and the set it replayed
    was a hand-written subset — the tenant, the role and the API key. That sufficed while no route
    required an actor. It does not now: a session authenticated with a user token carries X-ID and
    X-Username, neither of which was forwarded, so the converted device list would have refused it.
    The subset is replaced by gateway.IdentityHeaders(), read from the one place that writes them.
  • Connector intent: the device list request carries the caller's intent and the service builds
    the filter. The appended pair now counts against the filter limit instead of escaping it, and the
    intent branches on the value rather than on the parameter being present. The handler stays the
    only place that validates what a caller sent; the service checks the one thing appending can
    break, which is the item count.
  • Container alias: the rewrite states its intent ahead of the caller's query string. The binder
    reads the first value of a repeated parameter, so /api/containers?connector=false previously
    returned non-container devices.
  • query.Paginated / query.Sorted: a request embedding both a Paginator and a Sorter
    promotes two Normalize methods at the same depth, which cancel out. These accessors are how the
    wrapper reaches them without reflection.

Testing

Four seams. The wrapper suite drives all three shapes through a router with faked identity headers
and asserts the ceremony as a table. The route-table test asserts every claim states a reason, that
the exempt set is registered, that each converted route is both declared and mounted, and that a
route's two statements of anonymity agree — the gateway claim frees the handler from needing an
actor, the authenticator's allowlist is what lets the request past the credential check, and
nothing else joins them. The service test asserts which comparison the connector intent produces,
in both directions. The MCP tests cover the three credential shapes a caller can arrive with, and
the one that carries no actor at all.

Worth a reviewer's attention:

  • Wire changes. A failed device list no longer carries X-Total-Count. A 400 from the
    device-list guards carries a JSON body instead of an empty one, and each of the three now names
    the field the caller can act on. connector is now a bool, so connector=xyz returns 422; only
    the rewrite sends that parameter.
  • The device list's effective filter cap drops from 8 to 6, because the connector pair is
    appended before the count is checked. That is the limit meaning what it says — the pair no longer
    escapes it — but a caller sending 7 or 8 filters is now refused where it was served.
  • Normalization runs before validation, not in the order ADR 0001's prose lists them. Validating
    first turns order_by=garbage into a 400 instead of correcting it to desc, which every list
    route does today.
  • Two route tests changed, against the issue's "must pass unchanged". The device and MCP tests
    sent only a tenant and a role, which no authenticated request looks like; they now send the
    identity header the credential would carry.

Merge together with shellhub-io/cloud#2509. Between them, cloud does not compile against
shellhub master.

@otavio
otavio requested review from a team as code owners August 24, 2026 16:03
@otavio
otavio force-pushed the refactor/pure-handlers branch 2 times, most recently from ebffaa5 to f2cd7bb Compare August 24, 2026 17:04
@otavio

otavio commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Claude finished @otavio's task in 6m 4s —— View job


Code Review Complete

Reviewed 23 files across code quality, security, testing, language patterns, and architecture — no new issues found. The code looks good as-is.

Notable things I verified rather than flagged:

  • List writes X-Total-Count after the error check, from the count the handler returned (not the page length) — the historical drift this PR set out to fix. TestListWritesTheTotalCountAfterTheErrorCheck asserts both directions with count=42 on a 1-element page, so it genuinely exercises the returned count.
  • Identity headers cannot be spoofedIdentityFrom only reads the seven headers, and the authenticator middleware unconditionally Dels and re-stamps them before any shape runs. Scope resolution (AdminOrScope) and actor resolution both fail closed (403/401), and Unbounded/Anonymous are only reachable via explicit calls with a required non-empty reason (enforced at build/test time).
  • Connector intent filter math builds a fresh query.Filters slice and its MaxFilterItems check correctly accounts for the two appended entries; the /api/containers alias places connector=true first so a caller's connector=false cannot shadow it (echo's first-value-wins binder).
  • Cross-repo impact is limited to cloud/internal/admin/routes/routes.go, which already matches the new handler signatures — the acknowledged shellhub-io/cloud#2509 coupling. No other cloud call sites (requests.DeviceList, query.Paginated/Sorted, gateway.Actor, IdentityFrom) break. The 82 non-converted routes are untouched by the normalize-before-validate change, which lives only in the new wrapper.

To request another review round, comment /review.

@otavio
otavio force-pushed the refactor/pure-handlers branch from f2cd7bb to 58eb623 Compare September 3, 2026 16:29
@otavio

otavio commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Claude finished @otavio's task in 6m 56s —— View job


Code Review Complete

Reviewed 23 files across code quality, security, testing, language patterns, and architecture — no new issues found. The code looks good as-is.

Notable things I verified rather than flagged:

  • List writes X-Total-Count after the error check, from the count the handler returned (not the page length) — fixing the historical drift this PR targets. TestListWritesTheTotalCountAfterTheErrorCheck asserts both directions.
  • Identity headers cannot be spoofed. IdentityFrom only reads the seven canonical headers, and the global Authenticator.Middleware unconditionally Dels and re-stamps them (even for anonymous/nil identities) before any One/List/None wrapper runs. Scope resolution fails closed (403 on missing tenant) and actor resolution fails closed (401), so the wrapper is a tightening, not a loosening.
  • Unbounded/Anonymous are reachable only via explicit calls with a required non-empty reason, enforced at build/test time by TestRouteTableStatesEveryClaim. Only the health check uses them here, with stated reasons.
  • Container alias reorder is a fix, not a regression. connector=true&$1 places the intent first so echo's first-value-wins binder prevents a caller's ?connector=false from shadowing it — covered by TestContainerAliasCarriesTheConnectorIntent.
  • Connector intent filter math builds a fresh query.Filters, enforces MaxFilterItems against the appended pair, and keeps caller-supplied filter validation in the handler.
  • query.Paginated/query.Sorted accessors correctly route around the promoted-Normalize ambiguity from embedding both Paginator and Sorter; existing call sites use qualified selectors and still compile.
  • Cross-repo impact is limited to cloud/internal/admin/routes/routes.go, already aligned with the new wrapper API — the acknowledged shellhub-io/cloud#2509 coupling. The additive shared symbols (gateway.Actor, IdentityFrom, query.Paginated/Sorted, requests.DeviceList.Connector, requests.Empty) have zero other cloud consumers.

To request another review round, comment /review.

@otavio otavio added this to the 0.27.1 milestone Sep 3, 2026
@otavio
otavio force-pushed the refactor/pure-handlers branch from 58eb623 to 68bd9c9 Compare September 3, 2026 17:24
@otavio
otavio force-pushed the refactor/pure-handlers branch from 68bd9c9 to dccd57f Compare September 3, 2026 19:40
@otavio
otavio force-pushed the refactor/pure-handlers branch from dccd57f to b54f186 Compare September 3, 2026 21:02
otavio added a commit that referenced this pull request Sep 3, 2026
Fourteen routes answered with a page of a collection, and thirteen opened by hand-rolling the
same preamble: bind, normalize the paginator, normalize the sorter, unmarshal the base64 filter,
validate the filter fields, validate the sort fields, answer 400. One contract, written thirteen
times, in four different orders, with three of them skipping field validation altogether.

The order is now fixed, and it is the wrapper's: normalize the page, normalize the sort applying
the resource's default, decode the filter, hold the filter to the contract, hold the sort to the
contract, then the struct validation that was always last. Each step's failure was a 400 before
and is a 400 now, so reordering them changes no status.

The 400 gains a body: the invalid-entity error the converted device list already returned, which
the shared OpenAPI 400 component has documented all along. That is the deliberate behaviour
change. Three others ride along with the List shape and are worth naming:

  - the thirteen routes now require an actor, because the shape's wrapper resolves one. Every
    production caller carries X-ID; three test files did not, and now do.
  - /namespaces and /users/invitations declare an unbounded scope. Both answer across namespaces
    for a caller who may have selected none, so a bounded scope would 403 them.
  - the SSH identity list's 401 gains a body, having been a bodiless NoContent.

ListSSHIdentities keeps a check on actor.ID that looks dead next to the wrapper's own: the
wrapper refuses a *zero* actor, and an API-key actor is not zero — it names a namespace with no
person behind it, so its user ID is empty. The route is open to API keys, so the check is live.
It reads the role through gateway.RoleFromContext rather than from the actor, because a role is a
membership's, and an actor is not yet a member of anything.

Access policies, service accounts and SSH identities set X-Total-Count to the length of the page
they were about to return, because their services discarded the count the store had already
computed. The count now comes from the store. For the first two that is the same number today;
the point is that it stops being the handler's to decide the day the route paginates. The
service-account store still derives its count from the slice, which is now the store's business
and not the route's.

list_validation_test.go is deleted in the same change. It was a linter written as a 171-line
go/ast test, and it had already gone blind: it matched handlers by their gateway-context
parameter, so the device list left its coverage the moment #6941 converted that route, and it
kept passing. The rule it enforced now reads the route table, where a route appears whatever
shape its handler has.

This breaks cloud, which reaches across the module replace for services.NamespaceFilterFields.
That is the next rung, as A-cloud was; it cannot start until shellhub-io/cloud#2534 lands.

Implements the B rung of #7032.
Fixes: #7034
@gustavosbarreto
gustavosbarreto requested a review from a team as a code owner September 5, 2026 16:04
otavio added a commit that referenced this pull request Sep 5, 2026
Fourteen routes answered with a page of a collection, and thirteen opened by hand-rolling the
same preamble: bind, normalize the paginator, normalize the sorter, unmarshal the base64 filter,
validate the filter fields, validate the sort fields, answer 400. One contract, written thirteen
times, in four different orders, with three of them skipping field validation altogether.

The order is now fixed, and it is the wrapper's: normalize the page, normalize the sort applying
the resource's default, decode the filter, hold the filter to the contract, hold the sort to the
contract, then the struct validation that was always last. Each step's failure was a 400 before
and is a 400 now, so reordering them changes no status.

The 400 gains a body: the invalid-entity error the converted device list already returned, which
the shared OpenAPI 400 component has documented all along. That is the deliberate behaviour
change. Three others ride along with the List shape and are worth naming:

  - the thirteen routes now require an actor, because the shape's wrapper resolves one. Every
    production caller carries X-ID; three test files did not, and now do.
  - /namespaces and /users/invitations declare an unbounded scope. Both answer across namespaces
    for a caller who may have selected none, so a bounded scope would 403 them.
  - the SSH identity list's 401 gains a body, having been a bodiless NoContent.

ListSSHIdentities keeps a check on actor.ID that looks dead next to the wrapper's own: the
wrapper refuses a *zero* actor, and an API-key actor is not zero — it names a namespace with no
person behind it, so its user ID is empty. The route is open to API keys, so the check is live.
It reads the role through gateway.RoleFromContext rather than from the actor, because a role is a
membership's, and an actor is not yet a member of anything.

Access policies, service accounts and SSH identities set X-Total-Count to the length of the page
they were about to return, because their services discarded the count the store had already
computed. The count now comes from the store. For the first two that is the same number today;
the point is that it stops being the handler's to decide the day the route paginates. The
service-account store still derives its count from the slice, which is now the store's business
and not the route's.

list_validation_test.go is deleted in the same change. It was a linter written as a 171-line
go/ast test, and it had already gone blind: it matched handlers by their gateway-context
parameter, so the device list left its coverage the moment #6941 converted that route, and it
kept passing. The rule it enforced now reads the route table, where a route appears whatever
shape its handler has.

This breaks cloud, which reaches across the module replace for services.NamespaceFilterFields.
That is the next rung, as A-cloud was; it cannot start until shellhub-io/cloud#2534 lands.

Implements the B rung of #7032.
Fixes: #7034
…orter

A request type that embeds both query.Paginator and query.Sorter promotes two Normalize methods
at the same depth, and they cancel each other out: neither is reachable through the outer type,
and no interface over that name can be satisfied. Every list handler works around this by
naming the embedded fields, which only works when the handler knows the concrete request type.

GetPaginator and GetSorter are reachable because their names are unique, so code holding any
request can normalize the page and the sort order without knowing what it is holding.
…er is

The device list handler read a raw query parameter and appended two filter entries of its own.
It did so after validation, so the pair escaped the filter-count limit and a caller could buy
two extra entries by asking for containers. It also branched on the parameter being present
rather than on its value, which made connector=false mean the opposite of what it says.

The request now carries the caller's intent and the service builds the filter, following the
precedent the sorter's tiebreak field already set. The appended pair counts against the limit
like any other, and the intent reads the value.

Counting it has a consequence worth stating: the device list's effective cap on caller-supplied
filters drops from eight to six, because the pair is appended before the count is checked. A
caller sending seven or eight filters to /api/devices is now refused where it was served. That
is the limit meaning what it says, but it is a wire change and not only a refactor.

Only the count is re-checked at that point. The handler already validated what the caller sent,
and the pair appended here is a known-good platform comparison against a field the device list
knows, so the count is the one thing appending can break.

The marker itself is what the connector agent writes to a device's platform field. A connector
device is an ordinary device row; that marker is the only thing that sets it apart.

The result is a new filter set rather than an edit of the request, because a request is an
input and reusing one must not compound the filter.

The alias states its intent ahead of the caller's query string. The binder reads the first
value of a repeated parameter, so /api/containers?connector=false would otherwise have returned
exactly the devices the container endpoint exists to exclude.

The route test that covered this asserted only that an operator preceded a property, never
which devices the comparison kept. It is replaced by one that asserts the intent reaching the
service, and by service tests that assert the filter in both directions.
…ne place

The authenticator stamps seven headers through Identity.WriteTo, and everything downstream took
them apart again one accessor at a time. Nothing held the two spellings of that header set
together.

IdentityFrom is the read side of that write, and a round-trip test is what keeps them naming
the same headers: a field added to one and forgotten in the other passes review and the
compiler, and shows up only as an identity that quietly loses part of itself on the way to a
handler.

IdentityHeaders names that set for a third caller: anything replaying a request internally has
to carry the headers forward for the identity to survive the hop, and reading them from here is
what stops a replayed subset drifting out of step with the write.

An Actor is the identity narrowed to what a handler may see. Role and admin stay behind,
because they decide what the caller may do and the middleware answers that first. An actor is
not always a person: an API key and a device token name a namespace principal with no user
behind them, which is why the type carries the credential rather than a user ID alone.
Eighty-five handlers each rewrote the same five blocks, and the copies had already drifted
apart. X-Total-Count is written before the error check in two places and after it in ten, and
two sites report the length of the page rather than the count the service returned. Three
idioms answered "which namespace is this bounded to?", so a cross-tenant read could be
introduced in any handler and reviewing for it meant reading all of them. Nothing enforced one
answer, because no module held it.

One module holds it now. A route registers under One, List or None, and the wrapper binds the
request, normalizes the paginator and sorter, validates, resolves the namespace scope and the
actor, calls the handler and encodes the result. Each shape produces an echo.HandlerFunc, so
per-route middleware composes exactly as it does today.

Every route is bounded and needs an actor unless its registration says why not, and both
reasons are required arguments. The two claims are independent: a device authenticating with
its own token is bounded to a namespace and still carries no actor. Each registration records
what it claimed, which is what lets a test refuse a reason left empty -- Echo does not expose a
route's handler, so there is nothing else to enumerate.

The inventory of claims is a process-wide value keyed by the claim itself, so building the same
route table twice records it once. It exists to be read by a test and nothing in production
calls it: a required argument makes a reason impossible to omit, but only an inventory makes an
empty one impossible to merge. The authenticator's anonymous-route accessor is the same shape
for the same reason.

Scope and actor are resolved inside that per-request preparation rather than when the gateway
context is built, and the order is not incidental. The identity accessors read their values
back off the request headers lazily, and the authenticator writes those headers later than the
context is constructed. Resolving either one early reads headers that are not there yet, and
yields an empty scope and a zero actor rather than an error.

Normalization runs before validation rather than after. Validating first turns an out-of-range
page or an unknown sort order into a 400, where every list route today corrects it and carries
on.

The total count is written after the error check and from the count the handler returned: a
failed request must not answer with a count, and a count read back off the returned page is the
page size rather than the size of the collection.

The wrapper refuses a request when the gateway context is not installed, so a wiring mistake
fails closed rather than serving unscoped data. It keeps stashing that context in the request
context, which is how the service layer's tenant, username and identity lookups still reach it;
dropping that would break them silently, with no compile error.

The ceremony is asserted once, here, rather than re-tested per entity. A request embedding
neither a paginator nor a sorter is driven through the wrapper itself, which is where skipping
normalization is a real decision rather than a fact about Go's type system.
The first route to convert, and the one that proves both reason mechanisms: it reports on the
instance, which belongs to no namespace, and a load balancer asks it before any credential
exists. Neither claim is inferable, so both are typed at the route table where a reviewer reads
them.

The handler stops knowing about HTTP, so its test stops building a context to call it with.
…List shapes

The two device read routes become functions of their inputs. Both had the whole ceremony
written out; what is left is the part specific to the operation, which for the list is the sort
field and the caller's encoded filter, and for the read is nothing at all.

Three wire changes follow, all of them from the wrapper rather than from these handlers:

  A failed device list no longer carries X-Total-Count. It previously carried the count the
  service returned alongside the error.

  A 400 from the device-list guards carries a JSON body where it used to carry an empty one.
  Each of the three now names the field the caller can act on -- the sort field it rejected, or
  the filter it could not decode or validate -- rather than an empty set the UI cannot mark up.

  connector is a bound bool, so connector=xyz is now 422 rather than ignored. Only the container
  rewrite sends that parameter.

The MCP endpoint dispatches internally by replaying the caller's headers against the router, and
the set it replayed was a hand-written subset: the tenant, the role and the API key. That was
enough while no route required an actor. It is not enough now -- a session authenticated with a
user token carries its identity in X-ID and X-Username, neither of which was forwarded, so the
device list would refuse it. The subset is replaced by the identity header set itself, read from
the one place that writes it, which is also what stops the two drifting apart again.

The device route test sent only a tenant and a role, which no authenticated request looks like.
It now sends the identity header the credential would carry, and the MCP tests cover the three
credential shapes a caller can arrive with as well as the one that carries no actor at all.
A required argument makes a reason impossible to omit. Only an inventory of the claims makes an
empty one impossible to merge, so this reads back what the route table declared while it was
built and refuses a claim that says nothing. A second test proves that check bites rather than
passing because it looks at nothing.

The exempt set names the routes that are not resource operations and so keep a direct
registration. Echo does not expose a route's handler, so no test can prove that a route outside
the set went through a wrapper; what the set buys is the other direction, where joining it is a
visible edit a reviewer reads. Each member is cross-checked against the router, so an entry that
outlives its route fails.

Why each member is exempt, since the set itself can no longer say so:

  GET /api/install serves a shell script rather than JSON, so it answers with none of the three
  shapes.

  POST /api/login and POST /api/auth/user derive three non-200 outcomes from values that are not
  errors, and set two headers the console reads.

  POST /api/tags and POST /api/namespaces/:tenant/tags return the created identifier in a
  response header. Both leave this set once that identifier moves into the response body.

The three converted routes are pinned to both their shape and their address, which rules out a
claim recorded by a wrapper nothing mounted.

A route's anonymity is stated in two places that nothing else joins: the gateway claim frees the
handler from needing an actor, and the authenticator's allowlist is what lets the request past
the credential check. A route carrying one without the other is either unreachable or reachable
without a credential, so the two are now asserted to agree.

Fixes: #6940
@otavio
otavio force-pushed the refactor/pure-handlers branch from b54f186 to 76975b5 Compare September 5, 2026 19:21
otavio added a commit that referenced this pull request Sep 5, 2026
Fourteen routes answered with a page of a collection, and thirteen opened by hand-rolling the
same preamble: bind, normalize the paginator, normalize the sorter, unmarshal the base64 filter,
validate the filter fields, validate the sort fields, answer 400. One contract, written thirteen
times, in four different orders, with three of them skipping field validation altogether.

The order is now fixed, and it is the wrapper's: normalize the page, normalize the sort applying
the resource's default, decode the filter, hold the filter to the contract, hold the sort to the
contract, then the struct validation that was always last. Each step's failure was a 400 before
and is a 400 now, so reordering them changes no status.

The 400 gains a body: the invalid-entity error the converted device list already returned, which
the shared OpenAPI 400 component has documented all along. That is the deliberate behaviour
change. Three others ride along with the List shape and are worth naming:

  - the thirteen routes now require an actor, because the shape's wrapper resolves one. Every
    production caller carries X-ID; three test files did not, and now do.
  - /namespaces and /users/invitations declare an unbounded scope. Both answer across namespaces
    for a caller who may have selected none, so a bounded scope would 403 them.
  - the SSH identity list's 401 gains a body, having been a bodiless NoContent.

ListSSHIdentities keeps a check on actor.ID that looks dead next to the wrapper's own: the
wrapper refuses a *zero* actor, and an API-key actor is not zero — it names a namespace with no
person behind it, so its user ID is empty. The route is open to API keys, so the check is live.
It reads the role through gateway.RoleFromContext rather than from the actor, because a role is a
membership's, and an actor is not yet a member of anything.

Access policies, service accounts and SSH identities set X-Total-Count to the length of the page
they were about to return, because their services discarded the count the store had already
computed. The count now comes from the store. For the first two that is the same number today;
the point is that it stops being the handler's to decide the day the route paginates. The
service-account store still derives its count from the slice, which is now the store's business
and not the route's.

list_validation_test.go is deleted in the same change. It was a linter written as a 171-line
go/ast test, and it had already gone blind: it matched handlers by their gateway-context
parameter, so the device list left its coverage the moment #6941 converted that route, and it
kept passing. The rule it enforced now reads the route table, where a route appears whatever
shape its handler has.

This breaks cloud, which reaches across the module replace for services.NamespaceFilterFields.
That is the next rung, as A-cloud was; it cannot start until shellhub-io/cloud#2534 lands.

Implements the B rung of #7032.
Fixes: #7034
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Route wrappers for pure handlers, and the connector-filter prerequisite

1 participant