addon

package
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: MIT Imports: 51 Imported by: 0

Documentation

Overview

Package addon is the WASM host: it discovers add-ons in an operator-owned directory, verifies each module against its manifest, and instantiates it or refuses it.

What this is, and what it is not yet

This is the lifecycle every later capability hangs off, built before any of them so each seam lands inside a running host rather than beside a hypothetical one. A module is found, its manifest read, its declared ABI generation checked, its bytes hashed against what the manifest claims, and the runtime asked to instantiate it.

The imports it may resolve are the ABI, which is authored in internal/addon/abi and registered by hostabi.go. Count them from abi.Functions rather than from this comment, which has been wrong once: the live set grows as milestones land, and the rest are declared and refused with a status a module can branch on, because the contract crosses a repository boundary before the behaviour behind it exists. Template rendering and redirect observation are the ones still refused.

An add-on's own tables

A module that declares `storage.own_schema` gets a Postgres schema of its own, `addon_<name>`, and a login role of the same name that reaches nothing else. Its migrations arrive with it — a `migrations/` directory, each file named in the manifest with its digest — and the *host* applies them, at load, before the listener opens, as the add-on's own role. That last clause is the confinement: DDL naming another schema is refused by Postgres rather than by a parser here, and a SECURITY DEFINER function the DDL creates is owned by a role that can reach nothing. internal/store/addons.go is the whole of it, and its header says why `SET ROLE` on the application's own session is not a boundary.

What a module may *call* is narrower still: every function names the permission it costs, the manifest is where an add-on declares the ones it needs, and an undeclared call is refused and counted (M62). Grants are resolved once here at load — grants.go — and checked on every call in hostabi.go, because from M66 the check sits on the redirect path.

The runtime, and why it is wazero

The image is built CGO_ENABLED=0 — Dockerfile:86, and the Makefile's `dist` cross-compile loop, cited by target because the line number moved twice in one day (D218) — and stays so. wazero is the one production WASM runtime that needs no cgo, which is why it was named at planning rather than left to the build (D211).

What a module is handed

Nothing that is not required to start. Modules are built with GOOS=wasip1 GOARCH=wasm -buildmode=c-shared, so they import wasi_snapshot_preview1 and the start function is _initialize rather than _start — a reactor, which stays instantiated, rather than a command that runs main and exits. WASI preview 1 is instantiated once per host because a Go module cannot start without it; every capability *behind* it is left at wazero's default, and wazero's defaults are not the operating system:

  • no filesystem is preopened, so fd operations have nothing to reach;
  • no environment and no arguments are passed;
  • the clock and the random source are **not** wazero's defaults, and that is the one place this host deliberately departs from them. See [guestModuleConfig], which is the only place in this package a module config is built.

The departure is D292, and what it repairs is worth stating where the defaults are described. wazero's default random source is `rand.New(rand.NewSource(42))` — a compile-time constant, so every module on every deployment drew the same bytes — and its default clock starts at 2022-01-01 and advances a millisecond per reading. With a fresh instance per request (D260) that made *every visitor's* nonce identical rather than merely predictable, which is F292. A module's writes to stdout and stderr are still discarded — routing them into the operator's log would be a capability granted by accident, and the log function is the one that was granted on purpose.

Cost

Measured in TestInstantiationCostIsMeasured, which times compiling and instantiating **separately** because only one of the two is a cost a request could pay. On the fixture the standard toolchain produces (about 1.85 MB): compiling is the expensive step at a few hundred milliseconds and happens once per module at boot; instantiating that compiled module costs about 2 ms, and the same again for a second instance; the guest's linear memory is about 2.4 MB per instance and the host heap grows about 5.4 MB with it. M66 prices a per-request budget against those numbers rather than against a guess. D225 records them, including what the race detector does to the two durations.

Those are measurements of one fixture, and a measurement is not a bound. What bounds an instance is WithMemoryLimitPages below, added when M64 was reopened: before it, a module that asked for more simply got more, and the concurrency bound priced sixteen instances of whatever the module chose (F290). It binds however the module's memory section is written — an over-large *minimum* is refused at load, an over-large *maximum* is replaced by this limit while the section is decoded, and TestWhatAMemorySectionMayDeclare measures both.

Index

Constants

View Source
const (
	// CodeURLInvalid is a URL this host will not make a request out of at all:
	// not https, no host, credentials in it, or not a URL.
	CodeURLInvalid = "url_invalid"
	// CodeDigestInvalid is a digest that is not 64 hex characters. Its own code
	// rather than a mismatch, because the operator mistyped the field rather than
	// fetched the wrong thing.
	CodeDigestInvalid = "digest_invalid"
	// CodeDigestMismatch is the one that matters: the bytes arrived and they are
	// not the bytes the operator named.
	CodeDigestMismatch = "digest_mismatch"
	// CodeBundleInvalid is bytes that hashed correctly and are not an add-on
	// bundle, or are one holding something other than a manifest and its module.
	CodeBundleInvalid = "bundle_invalid"
	// CodeBundleExpands is a compressed bundle that unpacks to more than this host
	// will carry, or at a ratio nothing a publisher builds produces. Its own code
	// rather than [CodeBundleInvalid], because the archive parsed: what is wrong
	// with it is a number, and the operator can look at that number.
	CodeBundleExpands = "bundle_expands"
	// CodeBundleMismatch is a bundle whose module is not the file its own manifest
	// names.
	CodeBundleMismatch = "bundle_mismatch"
	// CodeFetchStatus is an origin that answered something other than 200.
	CodeFetchStatus = "fetch_status"
)

The refusals a URL install answers with, as codes rather than as sentences.

**A code per bound**, because m68.6.md asks for a refusal that says which bound bit rather than *the upload was refused*. The dashboard renders none of the messages — everything on that surface is attacker-influenced text and it words its own sentence from the code (internal/httpx/web_addons.go) — so the code is the whole of what crosses to a reader, and a bound with no code of its own is a bound an operator cannot act on.

URLInstallCodes is the closed set, and it is held from both ends. A test in internal/httpx holds the page's vocabulary against it, so a code added here without a sentence there is a failing build rather than a blank flash; a test in this package reads the outcomes [Host.fetchBundle] can arrive at out of the source that produces them, so a word added to [fetchFailure]'s switch without a code here is a failing build rather than a generic sentence. One direction alone would leave the list closed against the page and open against the wire.

View Source
const (
	// PermissionRedirectObserve is out-of-band observation.
	PermissionRedirectObserve = "redirect.observe"
	// PermissionRedirectInline is running on the path itself.
	PermissionRedirectInline = "redirect.inline"
	// PermissionRewriteQuery is altering the destination's query, and it is a
	// token of its own on top of the one above — D317.
	PermissionRewriteQuery = "redirect.rewrite_query"
)

The three grants this file branches on, named for the reason PermissionRoutes is: a second spelling of a permission is the drift a closed vocabulary exists to stop, and a test holds each against abi.Permissions.

View Source
const (
	ClassInline  = "inline"
	ClassObserve = "observe"
)

The two class labels, which are metric label values and therefore a closed vocabulary rather than free text.

View Source
const (
	StepInstantiate = "instantiate"
	StepCall        = "call"
)

The two steps a redirect-class invocation can be killed at, which are metric label values and a closed vocabulary for the reason the class labels are.

They name *whose bound was overrun* rather than where the code was: a kill at StepCall is the add-on holding a redirect past LINKCTRL_ADDON_INLINE_DEADLINE, and a kill at StepInstantiate is this host failing to start the module inside LINKCTRL_ADDON_INSTANTIATE_DEADLINE. An operator reads the first as *go and fix that add-on* and the second as *this instance cannot start add-ons fast enough*, and F326 is the outage-shaped bug that comes of the two being one number.

View Source
const CodeMigrationsUnsupported = "migrations_unsupported"

CodeMigrationsUnsupported is the field-error code an add-on shipping `.sql` files is refused with.

Exported because the dashboard has to tell this refusal apart from every other domain.ValidationErrors the install returns: they all map to `invalid`, and the sentence the page words for `invalid` names a digest, which for this case is both wrong and unactionable. The API always carried the message; the form carried a code, so the code is what had to become nameable.

View Source
const ContentTypeWrapped = ""

ContentTypeWrapped is the empty content type: the host wraps the body in the dashboard's own page template, escaped. It is the default, and it is the only way an add-on's output reaches a browser as part of an HTML document.

View Source
const DefaultFetchMaxBytes int64 = 256 << 10

DefaultFetchMaxBytes is how large a response body this host will accept.

256 KiB, which is twenty times the largest of the eight documents measured for DefaultFetchTimeout and the same number [maxResponseBody] uses for what an add-on may answer with — one figure for what crosses this boundary in either direction is one figure for an operator to reason about.

**A response over it comes back with no body at all**, as the `too_large` outcome. Truncating would hand an add-on a JSON document that fails to parse and let its author blame their own code.

View Source
const DefaultFetchTimeout = 3 * time.Second

DefaultFetchTimeout bounds one outbound request, connect through last byte of body.

Three seconds, and the measurement behind it is what an OIDC relying party actually fetches: on 2026-08-26 the discovery documents of four public providers were 839, 1,217, 1,399 and 1,728 bytes and their JWKS documents 2,880, 5,547 and 12,852 — documents small enough that the time is a round trip and a TLS handshake rather than a transfer. Three seconds is an order of magnitude over what that costs, and it is the largest value at which three of them — discovery, token exchange, key set, which is what an authorization-code flow makes — still fit inside DefaultRouteDeadline, which itself has to fit inside the request deadline. What internal/config **enforces** is the pair of nestings — this may not exceed the route deadline, and that may not reach the request timeout; what a test asserts is the three-fetch arithmetic above, because it is a claim about the *defaults* rather than a rule an operator has to obey. The first attempt at this milestone sized this number against a budget that did not exist, and neither of those two is a comment.

It is a ceiling and not a reservation: the fetch is bounded by this *or* by whatever is left of the invocation's own deadline, whichever comes first, so an add-on cannot buy time by fetching.

View Source
const DefaultInlineDeadline = 25 * time.Millisecond

DefaultInlineDeadline is how long an inline add-on's **own code** may hold a redirect open, unless an operator says otherwise with LINKCTRL_ADDON_INLINE_DEADLINE.

**Measured into, not chosen.** The upcoming-decisions entry M66 was planned with fixed the shape of this answer a phase in advance: one instance-wide knob, no per-add-on override until a real case argues for one, and a value taken from this milestone's own runs rather than from a guess. The runs are in docs/slo.md and the arithmetic is D318.

What the budget covers is the **guest call and nothing else** — the second half of an invocation, after this host has an instance to call into. It shipped covering instantiation as well, and that was F326: instantiating a module is this host's cost on this host's machine, and charging it to the add-on's budget meant that on hardware slower than the machine the 25 ms was measured on, every invocation died before the module's own code ran. The instantiation half is DefaultInstantiateDeadline now, which is deliberately wider and is bounded for a different reason. D327.

Measured on this machine, 2026-08-22: a fixture that reads its decision, probes six host functions and writes a query rewrite costs a mean of **3.27 ms** and a worst-of-twenty of **4.34 ms** end to end, against M60's separately measured ~1.6 ms to instantiate the same class of module. So 25 ms is roughly six times a module doing real work, and rather more than that now that it no longer pays for the instance. **Those figures are best-case** — an idle VM, nothing else on it — which is exactly what F326 was about, and D327 records that the number was confirmed by the owner on them.

**That figure is taken without the race detector**, which costs this measurement an order of magnitude — the same effect D225 records for the load path. The test reports the number either way and compares it against this constant only on a plain build; racecost_test.go says why.

It is deliberately **larger than the 20 ms cached-redirect target** and that is not a contradiction. The target is core's, measured with nothing on the path; the deadline is the point at which the host stops waiting for somebody else's code. Setting it under the target would make the host kill add-ons that were working, which trades an operator's feature for a number that no longer describes their instance anyway.

View Source
const DefaultInstantiateDeadline = 500 * time.Millisecond

DefaultInstantiateDeadline is how long this host will spend **starting** a module for a redirect-class invocation before it gives up and serves the redirect without it, unless an operator says otherwise with LINKCTRL_ADDON_INSTANTIATE_DEADLINE.

It exists because the two halves of an invocation are two parties' costs. What the guest does is the add-on's and is bounded by DefaultInlineDeadline; making the instance is this host's work on this host's machine, and its cost is a property of the hardware, the load and — in a test binary — the race detector, none of which the add-on chose. F326 is what one bound over both looked like: on a hosted runner every invocation died at StepInstantiate, the redirect completed, the kill counter moved, and nothing distinguished an add-on that declined to act from one that never ran.

**It is not borrowed from either number that already exists**, and it could not be. DefaultLoadTimeout bounds a module that hangs at boot at 30 seconds, and nothing on the redirect path may wait anything like that. The inline deadline is the number F326 proved too small for this. So it is argued from its own measurement and from what it costs when it is reached:

  • **Measured**, by TestInstantiationCostsWhatItCostsUnderContention, which instantiates the redirect fixture with every one of [addonSlots] slots busy — the state a redirect meets under load, not the idle one D318's numbers came from. On this machine, 2026-08-23: **mean 9.6 ms, worst of 128 62.7 ms**, against the ~1.6 ms M60 measured for one instantiation on an idle VM. Contention alone is therefore enough to put instantiation past the whole 25 ms inline deadline, on the fast machine, with no slow hardware involved — F326 was not only a CI-runner problem. Under `-race` the same run costs mean 91 ms and worst 304 ms, which no instance runs in but which is the closest thing this project has to a machine an order of magnitude slower.
  • **Priced**, because this is what a module that hangs in package initialization costs the redirect it arrived on — one visitor waiting, once per invocation, on a path whose core target is 20 ms.

So 500 ms, and the choice leans wide deliberately, because the two ways of being wrong do not cost the same. Too narrow is F326: add-ons silently do not run, on hardware nobody measured, and the counter blames the add-on. Too wide costs one visitor a longer wait in the case where a module is already broken, and it announces itself — `linkctrl_addon_redirect_kills_total{step="instantiate"}` moves, the log names the variable, and an operator lowers it. Half a second is eight times the contended worst case here, above the `-race` figure that stands in for far slower hardware, and sixty times under the 30 s a hanging module meets at load.

View Source
const DefaultLoadTimeout = 30 * time.Second

DefaultLoadTimeout bounds how long an add-on's **own code** may run in one step of its load.

**It bounds guest execution, and deliberately not the whole load.** Two steps of loadOne run code the add-on supplied — compiling the module, and instantiating it, which is where package initialization runs and where F287's hang was — and each of the two is given this budget. Nothing else in the load is inside it. The compile half is bounded only because [compileWorkers] is set: wazero's default compilation path does not stop for a context that is done, so the wrapper alone would have been a deadline nobody enforced. That is measured there rather than asserted here.

That distinction is the whole of the choice, and it is a choice about what the number *means* rather than about what the host does when it expires. A budget laid over the whole of loadOne is simpler to write and is wrong, because the load's expensive step is not the add-on's at all: applying an add-on's migrations waits up to **five minutes** for the migration lock — store's MigrateAddon, which says "the same five minutes the host's own migrations wait. A replica arriving mid-migration should wait rather than fail into a crash loop", and store's own Migrate is the twin of it. Thirty seconds over the whole load caps that wait at thirty seconds, so a second replica arriving mid-migration fails as load_timeout and a `required` add-on then stops the instance — the crash loop M63 chose five minutes to prevent, produced by the fix for F287. A first `CREATE INDEX` on a real table at upgrade meets the same bound with nothing wrong anywhere. Bounding the guest instead leaves that wait reachable and still catches the module that never returns, which is the only thing F287 ever asked for.

What it therefore does **not** bound is named rather than left to be discovered: an add-on's migrations are code it supplied too, and nothing here stops one statement in one of them running forever. The bound for that is Postgres's, on the connection the migration runs on, and it belongs beside where that connection is opened rather than here — F274.

**Per add-on, and not for the directory**, which is the other half of F287's fix and the part worth arguing. A single budget shared across the directory has three faults, and the second is disqualifying:

  • Attribution. One expired context tells an operator that *something* took too long. A deadline per add-on names the add-on in the log line and in the `addon` label of the metric, which is exactly what F287 says was missing — "nothing says which add-on boot is stuck on".
  • **It converts a `degrade` failure into a `required` one.** A shared context, once expired, is expired for every add-on after it in the directory. One `degrade` add-on spinning in `init()` would then fail every add-on behind it, and a `required` one among them stops the instance. That is the precise bullet this reopening exists to repair, re-broken from the other side.
  • Scaling. Ten installed add-ons would each get a tenth of the budget, so installing an eleventh could refuse the other ten.

The cost is stated rather than hidden, and docs/operations.md states it where an operator reads: N add-ons that all hang cost N times this before the listener opens, and an add-on that contrived to hang in both of its steps costs twice it. In practice one — compiling is a finite pass over a file of finite size, while instantiation runs a loop the add-on wrote. Each is still bounded, and each logs as its budget expires, so what an operator sees is progress with names on it rather than the silence F287 measured at twenty seconds and counting.

**The number is the one this milestone's own test already called the boundary.** TestInstantiationCostIsMeasured has asserted since M60 shipped that loading one add-on inside `Open` past 30 s "is not a boot-time cost any more"; this makes that assertion the host's behaviour instead of only the test's opinion, and the test now measures against this constant so the two cannot drift apart. Measured on this machine, 2026-08-20, at the two workers this host sets: a 1.87 MB fixture compiles in 211 ms and instantiates in 1.6 ms, so the whole of one add-on's guest execution is 213 ms — about a hundred-and-fortieth of the budget. The 380 ms figure eleven lines below is the same fixture at one worker, which is what the host used to do and no longer does. What this catches is a module that never returns, never a slow machine and never a slow database.

It is a constant and not a config field. An operator has no information with which to choose it: the number is about what the host will wait for, not about this deployment, and a knob here would be one more thing to get wrong in the direction of "unbounded". Options.LoadTimeout overrides it for tests, which need a budget they can afford to spend.

View Source
const DefaultPoolSize = 8

DefaultPoolSize is how many idle add-on instances this host keeps, across every add-on, unless an operator says otherwise with LINKCTRL_ADDON_POOL_SIZE.

**It is not a concurrency bound and it is deliberately not sixteen.** What bounds invocations in flight is [addonSlots], which this file does not touch; this bounds only what is held at rest, and the two are added rather than merged — see the file comment.

Eight is measured into. The redirect path's steady-state demand for instances is its arrival rate times how long one is held, and the k6 run in docs/slo.md holds one for a mean of 451 µs against the 11.05 ms M66 measured — so 2,000 redirects a second want about one instance at any moment. Eight is several times that and covers the bursts a Poisson arrival pattern produces around it: in that run nine redirects of 240,001 found no instance slot and none found the pool short, so the eviction path this bound guards did not run at all. Above eight the return is nothing and the cost is 8 MiB a slot, which is why the number is small rather than generous.

View Source
const DefaultPoolTTL = time.Minute

DefaultPoolTTL is how long an idle instance is kept before it is closed, unless an operator says otherwise with LINKCTRL_ADDON_POOL_TTL.

It is what keeps the idle cost proportional to traffic rather than to peak traffic that has since stopped. Without it a burst at midnight leaves eight instances holding guest memory until the process ends, which on an instance with one visitor an hour is the whole of the pool's cost and none of its benefit.

A minute, and the choice is loose on purpose: the number does not have to be right, it has to be finite. Too short costs an instantiation on the next redirect, which is what every redirect cost before this milestone; too long costs memory that is already bounded by DefaultPoolSize. Under any traffic worth pooling for, an entry is taken again within milliseconds and the sweep never sees it idle.

View Source
const DefaultRouteDeadline = 10 * time.Second

DefaultRouteDeadline is how long one request to an add-on's own route may take.

**It is a sub-request bound, and what makes it one is a number the first attempt at this milestone did not look at.** A route runs under the application tree's request context, and internal/httpx bounds that with LINKCTRL_HTTP_REQUEST_TIMEOUT — a *context* deadline, fifteen seconds by default, started strictly earlier than this one. So a route deadline of fifteen seconds never fired: the request deadline always closed the spinning instance first, measured at 300.7ms under a 300ms parent. The comment that defended the old number argued against LINKCTRL_HTTP_WRITE_TIMEOUT, which is thirty seconds and does not cancel a context, and so was arguing against the wrong bound entirely.

Ten seconds, and two things fix it once the request deadline is in view:

  • **It has to fire first, and the margin is what it buys.** When the request deadline is what elapses, host and guest end together and there is nothing left of the budget to turn the failure into a page, a log line or a counter. Five seconds under it means the host closes the guest, sees ErrGuestFailed, and still has a request to answer with. It is also the only bound at all when an operator sets LINKCTRL_HTTP_REQUEST_TIMEOUT to zero, which disables that middleware outright.
  • **It has to hold three fetches at DefaultFetchTimeout**, which is what an authorization-code flow costs — discovery, token exchange, key set. Nine of the ten seconds.

Neither relationship is left to arithmetic in a comment. internal/config refuses a fetch timeout over this and a value of this at or over the request timeout, for the reason it already refuses FEED_TIMEOUT over the request timeout — a knob whose upper half cannot take effect is not a knob. The three-fetch fit is a claim about the shipped defaults rather than a rule, so a test asserts it instead: TestTheAddonEgressBoundsNestInsideTheRequestDeadline.

**It is not a latency target.** A page an add-on draws is on the dashboard's budget like any other, and this is the point at which the host stops waiting for somebody else's code — the same thing DefaultInlineDeadline is for the redirect path, three orders of magnitude apart because the two paths promise different things. What it buys is that a module which loops, or which fetches in a loop, gives an instance slot back.

View Source
const InstallFetchTimeout = 10 * time.Second

InstallFetchTimeout bounds the whole of one bundle fetch, connect through last byte.

**Not DefaultFetchTimeout, and m68.6.md asks for the difference to be said out loud rather than inherited.** That number is three seconds and it was sized against a measurement of what an OIDC relying party fetches: discovery and key documents of 839 to 12,852 bytes, where the elapsed time is a round trip and a handshake rather than a transfer. A module is three orders of magnitude larger — the fixtures this repository builds are 1.8 MB to 3.6 MB, because a `GOOS=wasip1` binary from big-Go carries the runtime — so three seconds is a bound that would refuse ordinary installs on ordinary links.

Ten seconds, and the arithmetic is the same shape DefaultRouteDeadline's is, against the same ceiling. An install is a request in the application tree, so `LINKCTRL_HTTP_REQUEST_TIMEOUT` — fifteen seconds by default — is already cancelling the context this runs under, and a fetch bound at or above it would never fire. Ten leaves five seconds for what still has to happen after the last byte: hashing, unpacking, parsing, writing, and compiling a WebAssembly module, which is the expensive one. It is a ceiling and not a reservation — the fetch ends at this bound *or* at whatever is left of the request's own, whichever comes first.

**What it does not promise is MaxUploadBytes in ten seconds.** That would need 3.4 MB/s sustained, and a module near the cap over a slow link will time out here. That is stated rather than engineered around, because the answer for such a module already exists and is better: upload it, where the bytes travel on the client's own request and no server-side deadline is guessing at a link this instance cannot see.

View Source
const InvalidName = "<invalid>"

InvalidName is the addon label for a directory whose name could never be an add-on's, and it is a bound rather than a nicety.

The refusal path has no manifest to take a name from, so the label is the directory entry, which on Linux is any byte string but `/` and NUL. Two things then go wrong, and the first is not a metrics problem at all:

  • client_golang **panics** on a label value that is not valid UTF-8 — WithLabelValues, not the scrape — so a directory named in some other encoding would take the process down inside Open, at boot, before anything is serving. Measured, not inferred: the panic reads `label value "\xff\xfe" is not valid UTF-8`.
  • the cardinality claim above stops being true. Series would be bounded by how many directories exist rather than by how many add-ons are installed, and the two differ exactly when somebody is fixing a broken install.

Names nameRe accepts are used as they are; everything else lands here, and two badly named directories sharing one series is the point. The angle brackets are load-bearing: nameRe cannot produce them, so this cannot collide with a real add-on's name.

What is *not* wrong is the exposition itself. client_golang escapes a newline, a quote and a backslash in a label value, so those reach a scrape as `\n`, `\"` and `\\` and the text format stays line-oriented — checked, because the plausible-sounding version of this comment claims otherwise.

View Source
const ManifestFile = "addon.json"

ManifestFile is the name every add-on directory must hold. Fixed rather than configurable: discovery has to be able to tell an add-on from a directory the operator happened to leave there, and a name it looks for is the cheapest test that does not involve reading arbitrary files.

View Source
const MaxSettingValueBytes = 8 << 10

MaxSettingValueBytes bounds one stored setting.

Far above any credential — a JWK set pasted whole is a few kilobytes — and far below anything that makes a row worth worrying about. It exists because the column is unbounded text and the form body's own cap (`maxFormBytes`, 64 KiB) covers the whole submission rather than one field, so without this a single field could take all of it and the refusal would name the form rather than the setting.

View Source
const MaxSignInLabelBytes = 64

MaxSignInLabelBytes bounds the words an add-on may put on the sign-in page.

A constant rather than a number in a template, because the bound is a rule about hostile input and a template is not where a rule lives — the label is an add-on author's string rendered on the one page every visitor with an account meets. 64 bytes is comfortably more than the longest honest label (*Sign in with Contoso SSO* is 24) and far short of anything that could push the local form off a phone screen.

Bytes rather than runes, the same unit MaxSettingValueBytes uses, because the manifest is a file with a size and a publisher counting characters in a non-Latin script would still be told the byte figure by the refusal.

View Source
const MaxSignInPathBytes = 128

MaxSignInPathBytes bounds the declared path. It is not a security bound — Loaded.SignInHref is — only a refusal of a manifest carrying something that is not a path to a page.

View Source
const MaxUploadBytes = 32 << 20

MaxUploadBytes bounds the whole install request body.

32 MiB, and it is a bound on the *transfer* rather than a statement about what a reasonable module weighs. The fixtures this repository builds are 1.8 MB to 3.6 MB because a `GOOS=wasip1` binary from big-Go carries the runtime, and a module written in a language with a smaller one is a few hundred kilobytes; 32 MiB is past anything that shape produces and short of a body worth reading into memory by accident. It is read into memory rather than streamed for the reason the manifest is: the module is hashed before it is written, so the bytes are held either way, and streaming to disk first would mean writing an unverified module into the directory this instance executes from.

Documented in docs/configuration.md beside the manifest bound, because meeting an undocumented limit as a failed install is the same experience as a bug.

View Source
const MigrationsDir = "migrations"

MigrationsDir is the directory inside an add-on's own directory that holds its DDL. Fixed rather than configurable, for the reason ManifestFile is: the host has to be able to find it without being told, and a name is the cheapest test.

View Source
const PermissionRoutes = "routes.own_prefix"

PermissionRoutes is the grant a route costs. Named here for the reason abi.PermissionStorage is named in the ABI: this file branches on it, and a second spelling of a permission is the drift a closed vocabulary exists to stop. A test holds it against the vocabulary.

View Source
const PermissionSessionContext = "session.context"

PermissionSessionContext is the grant SessionContext costs, and it is separate from PermissionRoutes on purpose — D258.

View Source
const PermissionSessionMint = "session.mint"

PermissionSessionMint is the grant `session_mint` costs, and it is the reason a manifest declaring it is treated as `required` unless an operator says otherwise — see requiredByDefault.

View Source
const RemoveGrace = removeGrace

RemoveGrace is how long a removal waits for invocations already inside the module, exposed so the manager's confirmation can say what removing one costs rather than describing it in prose that could drift from the bound.

View Source
const RoutePrefix = "/addons/"

RoutePrefix is the path every add-on's routes live under. One segment, so the reserved-word list needs exactly one entry for the whole feature.

View Source
const SchemaVersion = 1

SchemaVersion is the manifest schema this host understands, and it is checked for equality rather than for "at least".

Versioned from the first commit, before any add-on exists, because the manifest is the first artifact that crosses a repository boundary — the OIDC add-on in DevOfPie/LinkCtrl-OIDC is built against it — and a schema that acquires its version field later cannot describe the manifests written before it. The cost is one integer per file; retrofitting it is a breaking change to every add-on published in the meantime. See m60.md's third risk.

View Source
const SignInConsentSetting = "sign_in_link"

SignInConsentSetting is the toggle an operator turns on to let an add-on's sign-in link appear.

A name, spelled like any other setting, because it is rendered by M68's existing form and saved by Host.SaveSettings — nothing about it is special to the page. What is special is who declares it: this host, for every add-on that asked, and no manifest may take the name.

View Source
const StartFunction = "_initialize"

StartFunction is what wazero is told to call at instantiation.

Named rather than left at wazero's default of _start, and the difference is the whole shape of an add-on: _start runs main and the module exits, which is a command. An add-on is a library the host calls into later, which the Go toolchain produces with -buildmode=c-shared and which starts at _initialize.

Variables

View Source
var (
	// ErrNoRoute is no such add-on, or one that did not declare
	// routes.own_prefix. The two are deliberately one answer: an add-on that did
	// not ask for a prefix does not have one, and telling a visitor which
	// add-ons are installed is not this surface's job.
	ErrNoRoute = errors.New("no add-on serves this prefix")
	// ErrNoHandler is an add-on that declared the grant and exports no handler.
	ErrNoHandler = errors.New("the add-on exports no request handler")
	// ErrNoResponse is a handler that returned without writing one.
	ErrNoResponse = errors.New("the add-on's handler wrote no response")
	// ErrBusy is addonSlots, reached.
	ErrBusy = errors.New("too many add-on requests are in flight")
	// ErrRequestTooLarge is a request whose record does not fit one value.
	//
	// Separate from the rest because it is the only one of them the *client*
	// caused: a body somebody sent is what made the record too big, so it is a
	// 413 and not a 502, and it is not written to the log at error. Nothing about
	// the add-on is wrong.
	ErrRequestTooLarge = errors.New("the request record is larger than one value may be")
	// ErrGuestFailed is the module trapping, or returning a status.
	ErrGuestFailed = errors.New("the add-on's handler failed")
)

Errors the routing path answers with. Each maps to one HTTP status in internal/httpx, and they are distinguishable because the operator's fix differs: a name nobody installed is a link somebody typed, a module with no handler is a packaging bug, and a busy host is capacity.

View Source
var ErrNoAddonDatabase = fmt.Errorf("%w: this instance has no database, so an "+
	"add-on's stored data cannot be reached", domain.ErrUnavailable)

ErrNoAddonDatabase is what a host with no database answers to an act that needs one — saving an add-on's settings, and purging what a removed add-on left.

Its own sentinel wrapping domain.ErrUnavailable for the reason ErrNoAddonsDir is: the caller did nothing wrong, and the operation would work on an instance that has one. In this product only a test builds such a host.

Worded for the *host* rather than for either act, because it is answered by both. It said *an add-on's settings cannot be stored* while Host.PurgeData returned it, which told an operator the wrong thing about a drop — and it was never seen, because cmd/linkctrl opens the pools before it opens the host.

View Source
var ErrNoAddonsDir = fmt.Errorf("%w: this instance has no add-ons directory; "+
	"set LINKCTRL_ADDONS_DIR and restart before installing an add-on", domain.ErrUnavailable)

ErrNoAddonsDir is what every lifecycle operation answers on an instance that configured no add-ons directory.

Its own sentinel wrapping domain.ErrUnavailable rather than a 404, because the caller did nothing wrong and the operation would work on an instance that set the variable. A 404 would say the endpoint does not exist, which is a different and less actionable thing to tell somebody whose install just failed.

View Source
var ResponseMediaTypes = []string{"text/plain", "application/json"}

ResponseMediaTypes is the closed vocabulary of media types an add-on may name for itself.

**text/html is not in it and will not be**: the host owns the HTML, which is what makes "an add-on cannot inject a script tag" a property of the shape rather than of a filter. text/plain and application/json are here because neither is a document a browser executes and both are things an add-on's own endpoint legitimately answers — a webhook receiver, a JSON fragment its page fetches. Both are served with X-Content-Type-Options: nosniff by the application tree's own middleware, so neither can be sniffed into markup.

View Source
var URLInstallCodes = []string{
	CodeBundleExpands,
	CodeBundleInvalid,
	CodeBundleMismatch,
	CodeDigestInvalid,
	CodeDigestMismatch,
	CodeFetchStatus,
	"fetch_address_refused",
	"fetch_connect_failed",
	"fetch_dns_failed",
	"fetch_origin_refused",
	"fetch_redirect_refused",
	"fetch_timeout",
	"fetch_too_large",
	CodeURLInvalid,
}

URLInstallCodes is every code above, plus one per fetch outcome that can reach an operator. Sorted, because it is a vocabulary rather than a sequence.

The fetch half is spelled `fetch_` + the word from abi.FetchOutcomes, so the counter an operator reads on the Add-on manager and the refusal they read on the install form use the same word for the same event.

Functions

func EncodeRequestBody

func EncodeRequestBody(body []byte) (string, bool)

EncodeRequestBody is how internal/httpx puts a body into a Request: as text when it is UTF-8, and as base64 when it is not, saying which.

Types

type Cookie struct {
	Name  string `json:"name"`
	Value string `json:"value"`
	// MaxAge is seconds. Zero is a session cookie; negative deletes.
	MaxAge int `json:"max_age"`
}

Cookie is one entry of a response's set_cookie array.

Name, value and a lifetime, and nothing else. Path, Secure, HttpOnly and SameSite are the host's — see Response — because a cookie an add-on could scope for itself is a cookie it could scope to the whole origin.

type DeclarationClass

type DeclarationClass string

DeclarationClass is how an add-on relates to the redirect path, as the manager's list column names it.

Three values, and they are the three the milestone's list column asks for. It is derived from what the add-on **holds** rather than from what its manifest declared, for the reason `linkctrl_addon_info`'s permission label is: a permission the vocabulary carries and no host grants yet is declarable and not held, and a column reading the manifest would promise behaviour the module cannot perform.

const (
	// ClassNone is an add-on that is not on the redirect path at all — pages,
	// authentication, storage. The commonest case.
	ClassNone DeclarationClass = "none"
	// ClassRedirectObserve is an add-on that sees redirects after the visitor has
	// been answered.
	ClassRedirectObserve DeclarationClass = "redirect-observe"
	// ClassRedirectInline is an add-on that runs inside the redirect, on the
	// visitor's own latency. Listed last because it is the one that can cost
	// somebody something.
	ClassRedirectInline DeclarationClass = "redirect-inline"
)

type FailureClass

type FailureClass string

FailureClass is what an add-on declares should happen when it will not load.

Declared by the add-on and not by the operator, which is the owner's answer of 2026-08-18: the module's author knows whether the instance is still the product without it, and an operator installing an authentication provider does not necessarily know that sign-in stops if it is skipped.

const (
	// ClassRequired stops the instance, naming the add-on and the reason.
	ClassRequired FailureClass = "required"
	// ClassDegrade logs, counts, and lets the instance serve without the add-on.
	ClassDegrade FailureClass = "degrade"
)

type FailureClassError

type FailureClassError struct {
	Addon string
	Var   string
	Value string
}

FailureClassError is an operator override this host cannot interpret.

Its own type because it is the one add-on failure that is neither the publisher's fault nor recoverable by degrading: the variable that says whether this add-on may be skipped is the variable that could not be read, so there is no answer to fall back to. Open returns it and the instance stops.

func (*FailureClassError) Error

func (e *FailureClassError) Error() string

type Grants

type Grants struct {
	// contains filtered or unexported fields
}

Grants is the permission set one add-on holds, resolved once at load.

**Resolved once, and the shape is load-bearing.** From M66 a grant check sits on the redirect path, where the inherited rule is a cached p99 under 20 ms, so Grants.Has has to be a lookup on an already-resolved set — never a read of the manifest, never a walk of the vocabulary, and never I/O. A test asserts it allocates nothing, and another asserts that editing a manifest's Permissions slice after load does not change what the add-on holds, because that is the falsifiable form of *resolved once*.

The zero value is a valid empty grant set, which is what an add-on that declared nothing holds. Every method is therefore safe on it.

func (Grants) Has

func (g Grants) Has(permission string) bool

Has reports whether this add-on holds the named permission.

func (Grants) Len

func (g Grants) Len() int

Len is how many grants are held.

func (Grants) Names

func (g Grants) Names() []string

Names is the held set, sorted, which is what a log line and a metric label carry. Sorted so that two boots of one instance produce the same string and an operator diffing them sees only real changes.

func (Grants) String

func (g Grants) String() string

String is the label form: the sorted names, comma-separated, and empty for an add-on that holds nothing.

type Host

type Host struct {
	// contains filtered or unexported fields
}

Host is the runtime and the add-ons instantiated in it.

Every method is nil-safe, because a nil *Host is the ordinary state of an instance that configured no add-ons directory and no caller should have to ask whether add-ons happen to be enabled.

func Open

func Open(ctx context.Context, opts Options) (*Host, error)

Open discovers, verifies and instantiates every add-on in opts.Dir.

Returns (nil, nil) when Dir is empty. That is the zero-cost case and it is exact: no runtime is constructed, no goroutine started, no metric series created and no route mounted, each of which is asserted by a test in this package rather than promised here.

Returns an error when a `required` add-on fails to load, or when one fails before its failure class could be read — see loadOne. The error names the add-on and the reason, and the caller's contract is to stop the instance with it: that is what the class means.

func (*Host) Addons

func (h *Host) Addons() []Loaded

Addons is what loaded, in discovery order. The slice is a copy; the instances inside it are not.

func (*Host) Close

func (h *Host) Close(ctx context.Context) error

Close shuts the runtime down, which closes every module in it.

func (*Host) Detail

func (h *Host) Detail(ctx context.Context, actor *auth.Identity, name string) (Managed, error)

Detail is one installed add-on with its settings resolved.

func (*Host) HasInline

func (h *Host) HasInline() bool

HasInline reports whether anything on this instance runs on the redirect path.

It is the check the redirect handler makes on **every** redirect, so it is one atomic load and a length: no lock, no allocation, no walk of the loaded set. Nil-safe, because an instance with no add-ons directory has no host at all and that is the case this has to cost nothing in.

It was a plain field read until M67, which made the set swappable — see set.go for why an atomic pointer was chosen over the RWMutex that would otherwise have landed on this line.

func (*Host) Inline

func (h *Host) Inline(ctx context.Context, d RedirectDecision) InlineResult

Inline runs every inline add-on against one decided redirect, in load order, and reports what they made of it.

Each module sees the destination as the module before it left it, which is the only composition that makes two installed add-ons mean what an operator would read them to mean: a rewriter that strips tracking parameters and a second one that appends a privacy signal compose, rather than one of them silently winning. **A veto ends the walk**, because there is no destination left to ask anybody else about.

Nil-safe and free when nothing is installed: the guard below is the whole cost on an instance with no inline add-on, which is every instance until an operator installs one.

func (*Host) InlineAddons

func (h *Host) InlineAddons() []string

InlineAddons is every add-on on the redirect path, in load order. The boot log and M68's manager read it; the path itself does not.

func (*Host) Install

func (h *Host) Install(
	ctx context.Context, actor *auth.Identity, req InstallRequest,
) (Installed, error)

Install verifies an uploaded module, writes it into the add-ons directory, and starts it — without restarting the instance.

The order is the whole security argument and it is the same order [loadOne] uses, moved one step earlier: the manifest is parsed and the module is hashed **before anything is written to disk**, so bytes that are not the bytes the manifest describes never reach the directory the instance executes from. The digest is then checked again by loadOne against the file it reads, which is not redundant — the first check is about what arrived, the second about what landed.

func (*Host) InstallFromURL

func (h *Host) InstallFromURL(
	ctx context.Context, actor *auth.Identity, req URLInstallRequest,
) (Installed, error)

InstallFromURL fetches a bundle, verifies it against the digest the operator supplied, and installs it exactly as an upload is installed.

func (*Host) Len

func (h *Host) Len() int

Len is how many add-ons are instantiated.

func (*Host) List

func (h *Host) List(ctx context.Context, actor *auth.Identity) ([]Managed, error)

List is every installed add-on, in discovery order, with what the manager's list page draws beside each.

Requires `addons.manage`. An add-on's name, version and declared permissions are an inventory of what this box runs — `/metrics` is not published for exactly that reason (docs/SECURITY.md) — so the page that prints them is behind the same scope as the API that installs them, and there is no lighter read.

func (*Host) Observe

func (h *Host) Observe(ev RedirectEvent)

Observe hands one recorded redirect to every observing add-on, out of band.

**It never blocks and it never fails.** The queue is bounded and a full one drops, which is the contract analytics.Ingester already keeps with the redirect path and is the reason this is safe to call from the pipeline's own goroutine: an add-on that is slow must not turn into a click batch that is late.

Nil-safe, and free on an instance where nothing declared the grant — the channel is nil then, and a send on a nil channel is not what happens, because the guard below returns first.

func (*Host) ObserveSchemaSizes

func (h *Host) ObserveSchemaSizes(ctx context.Context)

ObserveSchemaSizes publishes how much disk each loaded add-on's schema holds.

The metric is the whole of m63.md's answer to quotas: there is no cap on how large an add-on's schema may grow, for the reason there is no cap on the audit log, and the default is only defensible if the growth it permits is visible. Measured on a schedule from the maintenance job — see cmd/linkctrl/jobs.go, and the audit log's own gauge beside it for why every replica measures rather than only the leader.

Catalogue arithmetic per add-on, so the cost is two cheap queries times the number of installed modules. A failure is logged at debug and skipped: a measurement that could not be taken is not an operational event.

**Two, because a schema is not everything an add-on can fill.** A large object belongs to the role that created it and to no schema, so it is absent from the first measurement by construction; the second counts them, which is what keeps *stored growth is visible by metric* true for the kind of stored growth the schema's size cannot show. It is *the* kind only because the first measurement now sums every relation kind in the schema that has storage rather than a list of them: a sequence was a second kind, inside the schema and invisible to the list, which is D254. See store.AddonLargeObjects for why this one is a count and not a size — and for the qualifier: transient disk an add-on's session holds is neither gauge's subject, which is F279.

func (*Host) ObservingAddons

func (h *Host) ObservingAddons() []string

ObservingAddons is the same for the out-of-band class.

func (*Host) OrphanSchemas

func (h *Host) OrphanSchemas(ctx context.Context) ([]string, error)

OrphanSchemas is every `addon_*` schema in the database that no loaded add-on owns.

m63.md's *an orphan is detectable*, and it is detectable because the schema name is derived from the add-on's name rather than recorded: removing a module's directory removes the only thing that would have claimed the schema, and what is left over is exactly the set this returns. Nothing here deletes anything — a purge is an operator's explicit act, and [M68]'s flow.

Nil host, or a host with no database, answers nil and no error: an instance that configured no add-ons has no orphans to have, and saying so with an error would make every caller ask first.

func (*Host) Orphans

func (h *Host) Orphans(ctx context.Context, actor *auth.Identity) ([]Orphan, error)

Orphans is every add-on schema in this database that no installed module owns, with its size measured now.

Host.OrphanSchemas is the enumeration M63 built and this is the manager's reading of it: the same subtraction, plus the three measurements that make a purge offer honest — what the drop takes, and the two things keyed on the name that it leaves. Requires `addons.manage`.

func (*Host) PurgeData

func (h *Host) PurgeData(
	ctx context.Context, actor *auth.Identity, name string,
) (Orphan, error)

PurgeData drops one orphaned add-on's schema and everything in it.

It refuses to purge an installed add-on's data, and that is the whole check

The offer is made beside the orphan list, so the name always arrives from a row this instance drew. It is checked again here anyway: a `DELETE` is an address a client can type, the manager is not the only way to reach it, and dropping the schema out from under a running module is a failure mode with no upside — the add-on's next storage call would fail, its migrations would not re-run until the next load, and nothing would have been gained over removing the add-on first. The refusal is a conflict rather than a not-found, because the schema does exist; it is the state that is wrong.

And it refuses a name that names nothing

A schema that is not in the enumeration is a 404 rather than a silent success. `DROP SCHEMA IF EXISTS` would answer "done" for a typo, and an operator who mistyped a name would be told their data was deleted.

func (*Host) Remove

func (h *Host) Remove(
	ctx context.Context, actor *auth.Identity, name string,
) (Installed, error)

Remove unloads an add-on and takes its files out of the add-ons directory.

The order is the reverse of Install's and is load-bearing in the same way: nothing is closed until the add-on is out of the set *and* out of the directory, so a crash at any point leaves either a running add-on that is installed or no add-on at all — never a directory the next boot loads for a module this one deliberately unloaded.

The schema is left. That is M63's answer and not an oversight: removal creates an orphan, an orphan is enumerable, and offering to purge one is the surface's job at the point of decision (M68). Installed.Schema names it here so the caller can offer that choice.

func (*Host) Route

func (h *Host) Route(ctx context.Context, name string, in RequestIn) (Response, error)

Route hands one request to the add-on that owns the prefix it arrived under, and returns what the module answered.

Nil-safe: an instance with no add-ons directory answers ErrNoRoute, which is the 404 a visitor typing the path gets.

**Every error out of here is neutralized, at the exit and not at the site that built it** (D286). What internal/httpx does with one is log it, and a failure on this path carries the module's own text more often than not: a wasm trap names the guest's symbols out of a name section nothing constrains, and so does the instantiation failure a module can arrange for the per-request instance alone — hostState is registered before InstantiateModule and carries the request, so a guest can read that it is answering one and trap only then, loading clean and failing per visit. Neutralizing at each site was the shape that missed that one. Unwrap survives, so errors.Is on ErrNoRoute, ErrBusy and the rest still decides what it decided.

func (*Host) RoutedAddons

func (h *Host) RoutedAddons() []string

RoutedAddons is every loaded add-on holding the routes grant, in discovery order. It is what an operator's boot log and M68's manager read; the routing path itself does not consult it.

func (*Host) SaveSettings

func (h *Host) SaveSettings(
	ctx context.Context, actor *auth.Identity, name string, values map[string]string,
) ([]SettingView, error)

SaveSettings writes the values an operator typed into the manager's detail page.

What it refuses

A key the manifest does not declare, and a value its declared type does not admit: a `toggle` that is not `true` or `false`, a `select` that is not one of its options. Both are domain.ValidationErrors, so the page puts the message beside the field and the API answers 422 — the same shape every other form in this product uses. A setting the environment answers is refused too, and that is the load-bearing one: without it the page would accept a value, store it, and change nothing about what the add-on reads.

One transaction, and a full replace of what was sent

The form posts every editable setting, so a value that arrives empty means *unset* and its row is deleted rather than stored as an empty string — the environment route already reads a set-and-empty variable as unset, and two spellings of "no value" that behaved differently would be a trap. All of it is one transaction, so no `config_get` can observe half a form.

The audit record names the settings the save **touched** and never their values. Touched rather than *changed*, and touched rather than *wrote*: the form carries every editable field on every submission, so what is recorded is the set this save reached — which includes a key that arrived empty and had its row deleted, because clearing a credential is the half of this operation an auditor would most want to find. That is the same reading `updated_at` takes and is defended in query/addonsettings.sql — the question asked of a configuration record is *when was this last touched* rather than *when did it last differ*, and one record answering one way while the column beside it answered the other would be two accounts of one act. A save that carried a subset — the API's PUT can — records that subset.

No value is ever in it. A secret is the obvious reason; a non-secret is the same reason one step removed, because what an operator configures an add-on with is not a thing the audit log is a safe place for.

func (*Host) Schemas

func (h *Host) Schemas() []string

Schemas is the Postgres schema every loaded add-on owns, in discovery order.

Only the add-ons that declared storage have one, so this is shorter than Host.Addons whenever a module asked for none. It is what Host.OrphanSchemas subtracts and what the maintenance job measures.

func (*Host) ServesRoutes

func (h *Host) ServesRoutes(name string) bool

ServesRoutes reports whether a request naming this add-on would reach a module.

It is the shape test internal/httpx makes before the login limiter charges (D309): a path under `/addons/` naming nothing this instance serves is a 404 that costs its caller nothing, and only a request that would actually reach a module is charged against the budget somebody needs to sign in. Reading it off [Host.routed] is what keeps that from drifting away from what Route does.

No lock, no instance, no allocation: it runs on every request under the prefix including the flood it exists to make cheap.

func (h *Host) SignInLinks(ctx context.Context) []SignInLink

SignInLinks is what the sign-in page draws, ordered by add-on name.

**Sorted here rather than taken from the loaded set**, which is the one place the two available answers come apart. The loaded set is discovery order — os.ReadDir's over the add-ons directory, which is sorted, and an add-on's directory *is* its name — but M67's runtime install **appends**, so an add-on installed without a restart sits last until the next boot and then moves. Both halves of that are orders the host controls, and neither is stable: a link's position would change on a restart nobody connected to it. Sorting by name is the same order at boot and the only one that survives an install.

It is deliberately nothing a manifest can influence: *which sign-in method is listed first* is worth gaming, and an ordering field would be the one thing in this design an author could use to outrank another author.

Nil-safe on the host, which is every instance that configured no add-ons directory, and returns nil when nothing qualifies — the sign-in page renders byte-identically for both.

type InlineResult

type InlineResult struct {
	// Vetoed is a module refusing this redirect. The handler answers the gate
	// refusal with it, and nothing about the add-on reaches the visitor.
	Vetoed bool
	// Destination is where the visitor goes, which is the one that was handed in
	// unless a module rewrote the query and held the grant that costs.
	Destination string
	// Rewritten is whether Destination differs from what was handed in. The
	// handler does not need it; the tests do, and so does the log line that says a
	// module changed where somebody was sent.
	Rewritten bool
}

InlineResult is what the redirect path gets back from the inline classes.

The zero value is *nothing happened*, which is what an instance with no inline add-on produces and what Host.Inline returns on a nil host without touching anything.

type InstallRequest

type InstallRequest struct {
	// Manifest is `addon.json` verbatim, parsed by the same reader the boot path
	// uses so that an add-on installed through the API and one placed by hand are
	// refused for the same reasons.
	Manifest []byte
	// Module is the `.wasm`, hashed against the manifest's `sha256` before it is
	// written anywhere.
	Module []byte
}

InstallRequest is the pair that arrives in the request body.

**Bytes, never a URL, and the absence is the design.** A field naming somewhere to fetch the module from would make this the cleanest server-side request forgery in the product: an authenticated caller naming an address the server connects to, on a path whose whole job is then to execute what comes back. The product refuses that shape everywhere else — a link's destination is validated against a policy, a webhook's target is validated, a root redirect's is — and here there is no validation that would help, because the danger is the request rather than the response. So the bytes cross in the body the caller already has to send, and this struct has no third field.

type Installed

type Installed struct {
	Name         string       `json:"name"`
	Version      string       `json:"version"`
	ABIVersion   int          `json:"abi_version"`
	SHA256       string       `json:"sha256"`
	FailureClass FailureClass `json:"failure_class"`
	Permissions  []string     `json:"permissions"`
	// Schema is the Postgres schema this add-on owns, or empty. On a removal it is
	// the orphan that has just been created: removal deletes no data, and naming
	// the schema at the moment of the act is what stops the leftover being
	// something an operator discovers later (M63, and M68's purge offer).
	Schema string `json:"schema,omitempty"`
	// Draining is set on a removal whose in-flight invocations had not finished
	// when [removeGrace] expired, so the modules were closed under them. It is a
	// fact about what this instance did, not a warning to act on.
	Draining bool `json:"draining,omitempty"`
}

Installed is what a lifecycle act says about the add-on it acted on.

The same shape for both directions, because the useful record of a removal is the same set of facts as the record of an install: which module, at which version, with which digest. It is also what the audit metadata is built from, so the API's answer and the audit record cannot describe different things.

type JarCookie

type JarCookie struct {
	Name  string
	Value string
	// MaxAge is seconds, and follows the same convention a [Cookie] does: zero is
	// a session cookie, negative deletes. Negative is how an emptied jar is
	// cleared from a browser that still holds one.
	MaxAge int
}

JarCookie is one cookie the **host** writes for an add-on: the jar, not anything a module named.

internal/httpx writes these and nothing else — Response.SetCookie is emptied by the time a response leaves Host.Route, so a caller that reaches for the module's own list finds it gone rather than finding it writable. The path and every attribute are the writer's, as they always were.

type LoadError

type LoadError struct {
	// Addon is the directory the add-on was found in, which is its name: the two
	// are required to match, so this is knowable even when the manifest is the
	// thing that failed to parse.
	Addon   string
	Outcome Outcome
	Err     error
	// contains filtered or unexported fields
}

LoadError is why one add-on did not load, carrying the label the metric needs.

func (*LoadError) Error

func (e *LoadError) Error() string

Error neutralizes the directory name and nothing else: Err is neutralized where it is constructed, and this escaping doubles a backslash, so applying it twice would double it twice. The composition with %q is deliberate and is the one place a reader sees it — a directory name carrying a backslash reads as four, which is odd and is the safe direction to be odd in.

**Unbounded, and that is the fix F-5 named** (D286). This is what cmd/linkctrl prints when a `required` add-on refuses to load, and Manifest.Validate aggregates every problem with the manifest into one error precisely so that the person publishing an add-on for the first time sees the whole list. A 4 KiB cap belongs to a log line and was imported onto this path with the escaping; the two are separate concerns now, and only a log record gets the cap.

func (*LoadError) Unwrap

func (e *LoadError) Unwrap() error

type Loaded

type Loaded struct {
	Manifest Manifest
	// Dir is the absolute directory the add-on was loaded from.
	Dir string
	// Schema is the Postgres schema this add-on owns, or "" for one that did not
	// declare storage.own_schema. It is derived from the name rather than recorded
	// anywhere — store.AddonSchema is the derivation — which is what makes two
	// add-ons contending for one schema impossible rather than merely unlikely: the
	// directory *is* the name and loadOne refuses a manifest that disagrees.
	Schema string
	// FailureClass is what this host **applies**, which is the manifest's answer
	// only when neither of the two things that outrank it applied. Read this rather
	// than Manifest.FailureClass: the boot log, the info gauge and the decision to
	// stop the instance all use this one, and the manifest's field is the
	// publisher's declaration rather than the outcome.
	FailureClass FailureClass
	// contains filtered or unexported fields
}

Loaded is one add-on that instantiated.

func (Loaded) CanMint

func (l Loaded) CanMint() bool

CanMint reports whether this add-on holds the grant that lets it sign somebody in. Read off the resolved grants rather than the manifest, because a link is drawn on what a module can *do* — the same direction Host.SignInLinks takes for everything else about the offer.

func (Loaded) ConfiguredSettings

func (l Loaded) ConfiguredSettings() int

ConfiguredSettings is how many of this add-on's declared settings an operator has given a value. It is what M68's manager reads to draw "set" beside a secret it must never echo.

func (Loaded) Declaration

func (l Loaded) Declaration() DeclarationClass

Declaration is this add-on's class. Inline outranks observe: a module holding both runs on the path, which is the fact the column exists to surface.

func (Loaded) Grants

func (l Loaded) Grants() Grants

Grants is what this add-on holds, which is not the same as what its manifest declared: a permission the vocabulary carries and no host grants yet is declarable and not held. This is the readable form m62.md asks for — the boot log and linkctrl_addon_info carry the same set, and the Add-on manager reads it from here.

func (Loaded) MemorySize

func (l Loaded) MemorySize() uint32

MemorySize is the guest's linear memory in bytes, which is the resident cost of holding this add-on instantiated.

func (Loaded) Module

func (l Loaded) Module() api.Module

Module exposes the instance so a later milestone can call into it. It is the only reason this type is exported and there is nothing to call yet.

func (Loaded) ObservesRedirects

func (l Loaded) ObservesRedirects() bool

ObservesRedirects reports whether this add-on holds the out-of-band grant.

func (Loaded) PathPrefix

func (l Loaded) PathPrefix() string

PathPrefix is the prefix this add-on owns, with both slashes.

func (Loaded) RunsInline

func (l Loaded) RunsInline() bool

RunsInline reports whether this add-on holds the grant that puts it on the redirect path.

func (Loaded) ServesRoutes

func (l Loaded) ServesRoutes() bool

ServesRoutes reports whether this add-on holds the grant its prefix costs.

func (Loaded) SignInHref

func (l Loaded) SignInHref() (string, bool)

SignInHref is where this add-on's sign-in link points, and whether there is one.

**The composition is the host's, and the assertion is the point.** The declared path is joined onto the prefix this add-on was already given and the result is held against that same prefix, so a path that climbs out of it draws no link whatever the manifest said. Manifest.Validate refuses the shapes that would try — a leading separator, a scheme, a dot segment — and this is the second half: validation is what a publisher hears, and this is what a visitor gets, and neither is trusted to be the only one.

func (Loaded) SignInLabel

func (l Loaded) SignInLabel() (string, bool)

SignInLabel is the words this add-on asked to have drawn, held against the bound a second time.

Manifest.Validate refuses an over-long label at load, so this can only fire on a Loaded built by hand — and it fires rather than truncating, because a label cut in half is a button whose text somebody chose and nobody wrote.

type Managed

type Managed struct {
	Installed
	// Declaration is the list's CLASS column.
	Declaration DeclarationClass `json:"declaration"`
	// Declared is what the manifest asked for, which is m68.md's column and is not
	// always [Installed.Permissions] — that one is what the add-on **holds**, and a
	// permission this build publishes and grants to nobody is declarable and not
	// held.
	//
	// Both, rather than one, because they answer different questions and the
	// milestone asks for the first: *what does this add-on say it needs* is what an
	// operator agreed to when they installed it, and *what does it hold* is what
	// this build will let it do. The two are identical today — nothing declarable
	// is ungranted since M66 turned `redirect.inline` on — and the page marks the
	// difference rather than assuming it away, because the last time they diverged
	// it was for a whole phase.
	Declared []string `json:"declared_permissions"`
	// Performance is what this module cost, cumulative since this process started:
	// its invocations on the redirect path, and — since M68.5 — its outbound
	// requests, which are a different path with a different bound and sit beside
	// the redirect figures rather than inside them.
	//
	// Absent — `IsZero()`, which is `Observed()` false on **both** halves — for a
	// module with no record of either kind. The page draws a dash for whichever
	// half a module has no record of, and the API omits the whole object rather
	// than answering with one full of zeros. The two predicates ask different
	// questions and M68.5 is where they stopped coinciding: a module that has only
	// ever fetched has never run on the redirect path, so its row still draws a
	// dash there while its JSON carries the object.
	//
	// **It is in the JSON as well as on the page**, and that is the inherited *every
	// UI feature has API support* rule read as it is written: the figures are the
	// reason the manager exists, so a client that could list add-ons and not read
	// them would be a second surface with less in it. `/metrics` is not an answer
	// here — it is on a listener this product does not publish, for the reason the
	// add-on inventory is operational detail.
	Performance observability.AddonPerformance `json:"performance,omitzero"`
	// SchemaBytes is the on-disk size of the add-on's own schema, or 0 for a module
	// that declared no storage. Measured at render time; see this file's header.
	SchemaBytes int64 `json:"schema_bytes,omitempty"`
	// DeclaredSettings is how many settings the manifest declares and Configured is
	// how many have a value. The pair is what the list needs to say *3 of 5 set*
	// without reading a value it may not echo.
	//
	// **The manifest's own list, which since M69.5 is not all of [Settings].** An
	// add-on that asked for a sign-in link gets one more row on the detail page —
	// the host's consent toggle — and it is deliberately not counted here: this
	// figure answers *what did this add-on ask to be configured with*, and the
	// consent is this instance's question rather than the add-on's. Making the two
	// agree would mean calling the host's question the add-on's.
	DeclaredSettings int `json:"declared_settings"`
	ConfiguredCount  int `json:"configured_settings"`
	// Settings is the detail page's render model, and is nil on the list. Two
	// shapes rather than two types, because a list row and a detail page describe
	// one add-on and a second type would be a second thing to keep true.
	Settings []SettingView `json:"settings,omitempty"`
	// MemoryBytes is the resident guest memory of the load-time instance. What an
	// operator sizes a host by, and the number M64's bound is expressed in.
	MemoryBytes uint32 `json:"memory_bytes,omitempty"`
}

Managed is one installed add-on as the manager's list and detail pages see it.

It carries Installed rather than repeating its fields, so the lifecycle's answer and the manager's row cannot come to describe the same module differently — the same reason M67 made one summary serve both directions.

type Manifest

type Manifest struct {
	SchemaVersion int    `json:"schema_version"`
	Name          string `json:"name"`
	Version       string `json:"version"`

	// ABIVersion is which host ABI the module was built against. Parsed now,
	// consumed at M61, where an unsupported value becomes a refusal.
	ABIVersion int `json:"abi_version"`

	// Module is the .wasm this manifest accompanies, as a bare filename resolved
	// inside the add-on's own directory. Validation refuses a separator, so a
	// manifest cannot name a path out of it.
	Module string `json:"module"`

	// SHA256 is the digest of Module, lowercase hex. This is the load-time half
	// of the owner's "both" answer on build verification; the published-
	// provenance half belongs to the add-on's release process and is M69's.
	SHA256 string `json:"sha256"`

	FailureClass FailureClass `json:"failure_class"`

	// Permissions is what the add-on says it needs, and it is the whole of what it
	// may do: the vocabulary is abi.Permissions, every host function names the
	// grant it costs, and a call whose grant is not declared here is refused
	// (M62). Validation below refuses a token outside the vocabulary; whether a
	// declared one is actually *held* is resolveGrants', because a permission can
	// exist and be grantable by no host yet.
	Permissions []string `json:"permissions,omitempty"`

	Settings []Setting `json:"settings,omitempty"`

	// Migrations is the DDL this add-on ships, one entry per file, each with the
	// digest of the file it names.
	//
	// **Enumerated with a digest each rather than summarised**, and both halves
	// earn their place. The digest extends M60's answer from the module to the
	// DDL: the host runs these statements, an operator did not write them, and
	// the manifest is what makes them *the add-on author's* rather than whatever
	// is on disk. Enumerating closes the set — a `.sql` file present in the
	// directory and absent here refuses the add-on, so nothing can be added to
	// what the host will execute without editing the manifest that describes it.
	// A publisher computes these with the same `sha256sum` they already used for
	// the module, which is why it is a digest per file and not one aggregate over
	// a canonical ordering nobody could reproduce by hand (D247).
	//
	// Files live in the add-on's own `migrations/` directory and are goose SQL:
	// the host applies them at load, inside the schema the add-on owns, with the
	// add-on's own role. An add-on that lists any of these must also declare
	// `storage.own_schema`; validation refuses the pair being incoherent.
	Migrations []MigrationFile `json:"migrations,omitempty"`

	// CookiePrefixes is the cookie namespace this add-on owns: the request record
	// M64 hands it carries the cookies whose names begin with one of these and no
	// others, and the cookies it may set are bounded the same way.
	//
	// Declared rather than granted wholesale because this product's sessions are
	// server-side and opaque, which makes the Cookie header the credential itself
	// — an add-on handed it verbatim could act as whoever is signed in. Owner-set
	// 2026-08-18 (D232). Parsed and validated here, where the manifest's other
	// declarations live; consumed at M64, which is where a request first reaches
	// an add-on.
	CookiePrefixes []string `json:"cookie_prefixes,omitempty"`

	// SignInLabel is the words this add-on asks the sign-in page to draw, and
	// SignInPath is where inside its own M64 prefix that link should go (M69.5).
	//
	// **Two fields rather than one**, and the second is what keeps D364's rule —
	// the manifest names a need and never a destination — true on a second
	// surface. A label alone would have meant *link to my prefix root*, which is
	// a reserved meaning the ABI never gave that path: an add-on serving
	// `/addons/oidc/` at all is not thereby serving a sign-in there. Owner-
	// answered 2026-08-27.
	//
	// **Neither is a URL and neither is the target.** The host composes the href
	// from [RoutePrefix], this add-on's own name and this path, and then asserts
	// the result is still under the prefix it gave the add-on — see
	// [Loaded.SignInHref]. Validation below refuses the shapes that would try to
	// leave it (`..`, a leading `/`, a scheme, a host), so a manifest cannot name
	// a destination even before the composition is checked.
	//
	// **A label is not consent.** Declaring one is the add-on asking; the operator
	// agrees by turning on the [SignInConsentSetting] toggle on the Add-on
	// manager's detail page, and it is off until they do. An empty or absent label
	// draws nothing, so no add-on puts itself on the front door by omission.
	SignInLabel string `json:"sign_in_label,omitempty"`
	SignInPath  string `json:"sign_in_path,omitempty"`
}

Manifest is an add-on's identity and its intent, read before its code is.

Every field is consumed by a later milestone and none is decorative: ABIVersion by M61, Permissions by M62, Settings by M68, CookiePrefixes by M64. They are parsed and stored here so the file format is settled once, in the milestone that publishes it, rather than growing a field per milestone across a boundary another repository is already compiling against — CookiePrefixes being the exception that proves the cost, added by M61 the commit after this schema was written, because the ABI record it bounds was published in the same commit and a field is cheapest to get right before anything is built against it (D232).

func ReadManifest

func ReadManifest(dir string) (Manifest, error)

ReadManifest reads and validates the manifest in an add-on's directory.

Unknown fields are refused. That is the strict choice and it is deliberate: SchemaVersion is checked for equality, so a manifest carrying a field this host does not know was written for a schema this host does not implement, and accepting it would mean instantiating a module whose author expects behaviour that will not happen. A publisher who needs a new field needs a new schema version, which is what the field is for.

func (Manifest) Validate

func (m Manifest) Validate() error

Validate reports every problem with a manifest at once.

Aggregated rather than fail-on-first, for the reason config.Validate is: the person reading the output is publishing an add-on for the first time and should see the whole list, not discover it one boot at a time.

type MigrationFile

type MigrationFile struct {
	// File is a bare filename inside the add-on's `migrations/` directory. The
	// same refusal the module gets: a separator, a dot entry or a name that is not
	// `.sql` is a manifest that should not load rather than a path to clean up.
	File string `json:"file"`
	// SHA256 is the digest of that file, lowercase hex — byte-identical to what
	// `sha256sum` prints, for the reason [Manifest.SHA256] is lowercase.
	SHA256 string `json:"sha256"`
}

MigrationFile is one migration this add-on ships, and the digest of it.

type Minted

type Minted struct {
	// Token is the session cookie's value, or empty when a second factor is owed.
	// A Secret, so that no log line, no %v and no JSON encoder anywhere can turn
	// this struct back into a credential — the same wrapping an operator's
	// configured add-on settings get, and for a stronger reason.
	Token config.Secret
	// PendingToken is the second-factor challenge, or empty. Also a Secret: it is
	// a bearer credential for one operation, and one operation is a session.
	PendingToken config.Secret
	// ExpiresAt is whichever of the two above expires.
	ExpiresAt time.Time
	// SecondFactorRequired distinguishes the two without reading either Secret.
	SecondFactorRequired bool
}

Minted is what the host carries out of Host.Route when a module's assertion produced something. It is not part of the ABI and no module sees it.

type MintedSession

type MintedSession struct {
	ExpiresAt string `json:"expires_at"`
	// SecondFactorRequired is true when the host stopped at its own prompt. The
	// module's own `location` is then where the visitor lands *after* the prompt
	// rather than immediately — the host interposes, because the pending
	// credential is the host's and there is no shape in which a module holds one.
	SecondFactorRequired bool `json:"second_factor_required"`
}

MintedSession is the record the host writes back, and the whole of what an add-on learns about the session it caused.

Not the session: no token, no cookie, no row identifier and no account identifier. What a module can do with this is decide which page to send the person to, which is the only thing it needs it for — and if it wants to know who is now signed in, `session_context` on the *next* request is the read half and costs its own grant.

type Options

type Options struct {
	// Dir is LINKCTRL_ADDONS_DIR. Empty means there is no host at all — Open
	// returns a nil *Host, constructs no runtime and starts no goroutine.
	Dir string

	Logger  *slog.Logger
	Metrics *observability.Metrics

	// DB is the application's own pool, used for the two things an add-on's
	// storage needs the *product's* privileges for: creating the schema and the
	// role an add-on is confined to, and reading the catalogue to enumerate and
	// measure them. No add-on's statement ever runs on it — that is what the
	// per-add-on pool in store.AddonDB is for, and the separation is the boundary
	// rather than a tidiness.
	DB *pgxpool.Pool
	// DSN is the same database, as a connection string, because an add-on's own
	// pool authenticates as a different role and a pool cannot be re-pointed at
	// one. Everything else about the connection — host, port, database, TLS — is
	// inherited from it, so there is no second connection string to configure.
	//
	// Empty means no storage: the schema is not created, the migrations are not
	// applied, and a call to a storage function answers StatusInternal after
	// saying so in the log. That is a host constructed without a database, which
	// in this product is a test and never an instance — cmd/linkctrl opens the
	// pools before it opens the host, and both of these come from the same place
	// the migrations did.
	DSN string

	// Settings is where an add-on's configured values come from, asked for the
	// settings its manifest declares. Nil means the environment, through
	// [config.AddonSettings], which is what an instance uses; a test substitutes
	// values without writing to the process environment.
	Settings func(addon string, declared []string) map[string]config.Secret

	// Overrides is where an operator's per-add-on answers come from — the two
	// names in [config.AddonOverrideNames], which are not settings and which no
	// manifest may declare. Nil means the environment, through
	// [config.AddonOverrides]; a test substitutes them without writing to the
	// process environment, which is what lets the tests that exercise them run in
	// parallel.
	Overrides func(addon string) map[string]string

	// Sessions is what answers `session_mint` (M65). Nil is a host that cannot
	// mint — every unit test in this package, and no instance — and such a host
	// answers StatusInternal rather than pretending the function is not
	// implemented, because *this host has no database* and *this ABI does not have
	// that function* are different facts and a module branches on them differently.
	Sessions SessionMinter

	// Audit is what records the two lifecycle acts (M67). Nil records nothing,
	// which is a host built by a test; an instance passes the audit service, and
	// installing code into a running server without a record of who did it is the
	// one thing this surface must not be able to do quietly.
	Audit audit.Recorder

	// LoadTimeout is how long one add-on may take to load. Zero means
	// [DefaultLoadTimeout], which is what an instance uses; a test sets a budget it
	// can afford to spend watching a module that will not return.
	LoadTimeout time.Duration

	// InlineDeadline is how long an add-on's own code may hold a redirect open
	// (M66). Zero means [DefaultInlineDeadline], and an instance sets it from
	// LINKCTRL_ADDON_INLINE_DEADLINE. Unlike LoadTimeout it is an operator's knob
	// and not only a test's, because what it bounds is somebody else's code on the
	// path this product makes a latency promise about — see redirect.go.
	InlineDeadline time.Duration

	// InstantiateDeadline is how long this host will spend starting a module for a
	// redirect-class invocation (M66, reopened). Zero means
	// [DefaultInstantiateDeadline], and an instance sets it from
	// LINKCTRL_ADDON_INSTANTIATE_DEADLINE.
	//
	// **A test sets it for a reason no other bound here has.** It is the one number
	// in this package whose right value depends on the machine, so a suite that
	// leaves it at the default is a suite asserting that this machine is fast:
	// F326 shipped because five integration tests were green here and could not
	// pass on a hosted runner. Behaviour tests therefore buy room with it, and the
	// tests that are *about* the bound set a hostile one and make a slow machine
	// reachable on any machine at all.
	InstantiateDeadline time.Duration

	// PoolSize is how many idle add-on instances this host keeps across every
	// add-on (M66.5). Zero means [DefaultPoolSize], and an instance sets it from
	// LINKCTRL_ADDON_POOL_SIZE.
	//
	// It is not a concurrency bound. What bounds invocations in flight is
	// [addonSlots] and the pool takes nothing from it; this is what may be
	// held at rest, which is the term the guest-memory ceiling gained when an
	// instance stopped being destroyed after every redirect.
	PoolSize int

	// PoolTTL is how long an idle instance is kept before it is closed for lack of
	// traffic (M66.5). Zero means [DefaultPoolTTL], and an instance sets it from
	// LINKCTRL_ADDON_POOL_TTL.
	PoolTTL time.Duration

	// RouteDeadline is how long one request to an add-on's own route may take,
	// start to finish (M68.5). Zero means [DefaultRouteDeadline], and an instance
	// sets it from LINKCTRL_ADDON_ROUTE_DEADLINE.
	//
	// **The bound m68.5.md required before anything was allowed to fetch**, and it
	// applies to every route invocation rather than only to the ones that do — a
	// deadline conditional on a permission would leave the hole open for every
	// add-on that did not declare it while being a second rule to reason about.
	//
	// It is a bound **inside** the caller's, not the first one a route handler ever
	// had. An application request already carries LINKCTRL_HTTP_REQUEST_TIMEOUT's
	// context deadline and that cancels this same context, so the value only means
	// anything while it is shorter; internal/config refuses one that is not. What
	// the margin buys is a host that is still alive when the guest is killed, and
	// what the bound buys outright is an instance slot back from a module that will
	// not return — including on an instance that has turned the request timeout
	// off. See [DefaultRouteDeadline].
	RouteDeadline time.Duration

	// FetchTimeout bounds one outbound request an add-on makes (M68.5). Zero means
	// [DefaultFetchTimeout], and an instance sets it from
	// LINKCTRL_ADDON_FETCH_TIMEOUT.
	FetchTimeout time.Duration

	// FetchMaxBytes is the largest response body an add-on's fetch may bring back
	// (M68.5). Zero means [DefaultFetchMaxBytes], and an instance sets it from
	// LINKCTRL_ADDON_FETCH_MAX_BYTES.
	FetchMaxBytes int64
}

Options is what a host needs. Everything but Dir is optional.

type Orphan

type Orphan struct {
	Name   string `json:"name"`
	Schema string `json:"schema"`
	// Bytes is every relation in the schema that has storage, with its indexes and
	// its TOAST — store.AddonSchemaBytes, measured now.
	Bytes int64 `json:"bytes"`
	// LargeObjects is how many large objects the schema's role owns. **They are not
	// deleted by a purge** — they live outside every schema — so the number is here
	// to be honest about what is left rather than to describe what goes.
	LargeObjects int64 `json:"large_objects"`
	// IdentityLinks is how many `addon_identity_links` rows were written under this
	// name, and it is here for the same reason: **a purge deletes none of them.**
	//
	// One of the four things `PurgeAddonSchema` leaves standing, and one of the two
	// an operator is least likely to predict — the mappings are keyed on the
	// add-on's *name*, so a different module installed under a name that has been
	// used before inherits every account mapping the previous one wrote and can
	// mint a session against them on its first assertion (docs/SECURITY.md; F330
	// carries the removal-side answer). The confirmation is the point of decision
	// where that can still be acted on, so the number is measured for it rather
	// than described in prose the page does not carry.
	IdentityLinks int64 `json:"identity_links"`
	// StoredSettings is how many `addon_settings` rows were saved under this name,
	// and it is the fourth. Same key, same inheritance, same silence: a value an
	// operator typed into the manager's detail page — possibly a `secret` — is
	// keyed on the add-on's name (04800), survives both the removal and the purge,
	// and is handed to whatever is installed under that name next. **Nothing in
	// this product deletes one**: `SaveSettings` refuses a name that is not loaded,
	// so a removed add-on's rows are unreachable from every surface here. F332 in
	// docs/build-notes/deferred-findings.md carries that half. What this number
	// buys is that the point of decision says so with a figure instead of leaving
	// it to the migration's comment.
	StoredSettings int64 `json:"stored_settings"`
	// Measured says the size in [Orphan.Bytes] came from the catalogue rather than
	// from the read having failed.
	//
	// Not in the JSON: a client reading a *list* can ask again, and the list is the
	// only place this type is answered before the schema is gone. The audit record
	// is where it matters, because that row is the durable answer to *how much did
	// that delete* and `0` is what an empty schema honestly measures — so a failed
	// read written as a figure is indistinguishable from a true one, in the one
	// record that outlives the thing it describes.
	Measured bool `json:"-"`
}

Orphan is an `addon_*` schema no installed module owns.

The name is the add-on it belonged to rather than the schema, because that is the string an operator recognises; [Schema] is what will actually be dropped and is shown beside it, because a confirmation that names something other than what it deletes is not a confirmation.

type Outcome

type Outcome string

Outcome is the closed vocabulary of load results, and it is a metric label.

Nine values, so the series count is the number of installed add-ons times nine however many times the instance restarts. No error string is ever a label, and the only filename that can become one is bounded by nameRe or collapsed to InvalidName — see labelFor, which is what keeps the sentence above true of the refusal path, where the label is a directory entry and not a validated name.

const (
	OutcomeLoaded           Outcome = "loaded"
	OutcomeManifestInvalid  Outcome = "manifest_invalid"
	OutcomeChecksumMismatch Outcome = "checksum_mismatch"
	OutcomeModuleUnreadable Outcome = "module_unreadable"
	// OutcomeABIUnsupported is a manifest whose abi_version this host will not
	// serve: built against a newer generation, or against one whose deprecation
	// window has closed. Its own label rather than manifest_invalid, because the
	// manifest is not invalid — it is a perfectly good manifest for a different
	// host, and the operator's fix is a version rather than a syntax error.
	OutcomeABIUnsupported    Outcome = "abi_unsupported"
	OutcomeInstantiateFailed Outcome = "instantiate_failed"
	// OutcomeStorageFailed is the add-on's schema, role or migrations (M63). Its
	// own label rather than instantiate_failed, because the module is fine and the
	// operator's fix is a database one: a privilege the application's user does not
	// hold, a migration the add-on's author wrote wrongly, or DDL that reached
	// outside the schema the add-on owns. Which of those it was is in the log; the
	// label is what tells an operator where to look.
	OutcomeStorageFailed Outcome = "storage_failed"
	// OutcomeNameCollision is two installed add-ons whose names stand in a
	// `name + "_"` prefix relation — see nameCollisions, which is where the whole
	// rule is. Its own label rather than manifest_invalid for the reason
	// abi_unsupported is: neither manifest is invalid, each is a perfectly good
	// manifest on its own, and the operator's fix is a directory name rather than
	// anything inside a file.
	OutcomeNameCollision Outcome = "name_collision"
	// OutcomeLoadTimeout is an add-on that did not finish loading inside
	// [DefaultLoadTimeout] — see the deadline there for what that bounds and why.
	//
	// Its own label rather than instantiate_failed for the reason abi_unsupported
	// and name_collision are theirs: nothing is malformed, and the operator's
	// question is a different one. A module that traps at instantiation is broken
	// and the log carries the trap; a module that never returns is *running*, and
	// the only fact anyone has is that the budget ran out. Folding the two together
	// would have made the one alert an operator needs — an add-on is spending boot
	// — indistinguishable from the ordinary case of a bad build.
	OutcomeLoadTimeout Outcome = "load_timeout"
)

type RedirectAnswer

type RedirectAnswer struct {
	Verdict string `json:"verdict,omitempty"`
	Rewrite bool   `json:"rewrite,omitempty"`
	Query   string `json:"query,omitempty"`
}

RedirectAnswer is what an inline module wrote back.

type RedirectDecision

type RedirectDecision struct {
	LinkID      uuid.UUID `json:"link_id"`
	WorkspaceID uuid.UUID `json:"workspace_id"`
	Alias       string    `json:"alias"`
	Destination string    `json:"destination"`
}

RedirectDecision is where a visitor is about to be sent, as an inline add-on sees it. The ABI record is abi.Records' RedirectDecision and the field names here are that record's.

Nothing on it is derived from the visitor, which is a bound rather than an omission: an inline module holds somebody's request open, and what it is entitled to know is the decision it is being asked about. Watching visitors is the observe class's, off the path, under a grant an operator declares separately.

type RedirectEvent

type RedirectEvent struct {
	LinkID       string `json:"link_id"`
	WorkspaceID  string `json:"workspace_id"`
	OccurredAt   string `json:"occurred_at"`
	VisitorHash  string `json:"visitor_hash"`
	IsFirstVisit bool   `json:"is_first_visit"`
	Country      string `json:"country"`
	Device       string `json:"device"`
	Browser      string `json:"browser"`
	OS           string `json:"os"`
	Language     string `json:"language"`
	ReferrerHost string `json:"referrer_host"`
	IsBot        bool   `json:"is_bot"`
}

RedirectEvent is one redirect this instance served, as an observing add-on sees it. Every field is one click_events may carry, which abi_test.go asserts against the migration rather than against this struct.

type Request

type Request struct {
	Method string `json:"method"`
	// Path is the path *within* the add-on's prefix, always beginning with "/".
	// An add-on therefore cannot tell which prefix it was mounted under from the
	// request, which is deliberate: the prefix is its name and it knows its name.
	Path           string            `json:"path"`
	Query          string            `json:"query"`
	Cookies        map[string]string `json:"cookies"`
	ContentType    string            `json:"content_type"`
	AcceptLanguage string            `json:"accept_language"`
	Body           string            `json:"body"`
	// BodyBase64 says how to read Body, and it is the field that makes the
	// record's own sentence — "the body, base64 when it is not UTF-8" —
	// decidable. Without it a guest could not tell a base64 body from a body
	// that happens to look like base64, which is D262.
	BodyBase64 bool `json:"body_base64"`
}

Request is the HTTPRequest record, host-side.

Every field is one the ABI declares, and the absences are the point: no header map, no client address in any spelling, and no Cookie header — only the cookies whose names begin with a prefix this add-on's manifest declared (D232). internal/httpx builds it; nothing here reads an *http.Request, so what crosses is decided in one place and is the same for every add-on.

type RequestIn

type RequestIn struct {
	Method string
	// Path is already relative to the add-on's prefix, and must begin with "/".
	Path           string
	Query          string
	ContentType    string
	AcceptLanguage string
	Body           []byte
	Cookies        []*http.Cookie

	// ClientIP and UserAgent are the request's, and they are here for exactly one
	// consumer: a session minted through `session_mint` records where the sign-in
	// came from, in the same columns and by the same reduction the password path
	// uses. **Neither reaches the guest.** RequestIn is the host's input type and
	// [RequestIn.record] is what turns it into the record a module sees; neither
	// field is copied into it, no ABI record has a field for either, and
	// abi.AddressBearing fails the surface test if one ever acquires a name that
	// reads like an address. The address itself becomes a /24 or /48 prefix inside
	// internal/auth before it reaches a column, which is where every other address
	// in this product is reduced.
	ClientIP  netip.Addr
	UserAgent string

	// Identity is whoever the *host* resolved for this request, or nil for
	// nobody. It is the single source of truth about who is signed in, and
	// [RequestIn.session] is what a module is allowed to see of it.
	//
	// **One value rather than two**, which M65 is why. Two host functions have
	// opposite requirements about a session — `identity_link` refuses unless
	// somebody is signed in, `session_mint` refuses unless nobody is — and the
	// record a module sees is blanked for an add-on that did not declare
	// `session.context`. Passing the record as well would therefore have let an
	// add-on's own manifest decide whether the host noticed a session, which is
	// the wrong thing to be able to arrange from inside a manifest.
	Identity *auth.Identity
}

RequestIn is everything about an HTTP request that *could* cross this boundary, handed over by internal/httpx before the host decides what does.

It exists so that "no cookie of the host's reaches an add-on" is enforced in one place, by the code that knows the manifest, rather than by every caller remembering to filter. internal/httpx hands over the cookies the browser sent — including the session cookie, deliberately — and [RequestIn.record] is what drops all but the ones an add-on declared a prefix for. A test sends a real session cookie and asserts it does not cross.

type Response

type Response struct {
	Status      int      `json:"status"`
	ContentType string   `json:"content_type"`
	Location    string   `json:"location"`
	SetCookie   []Cookie `json:"set_cookie"`
	Body        string   `json:"body"`
	// Jar is what the host writes to the browser for the cookies above, and it
	// is not part of the record: a module neither sends it nor sees it.
	//
	// [Host.Route] fills it and **empties SetCookie doing so**, which is the
	// structural half of F289's fix. The list a module wrote is gone by the time
	// a response leaves this package, so a writer — the one in internal/httpx, or
	// one a later milestone adds without having read this comment — has nothing
	// to loop over but the jar, and the jar is at most two cookies however many a
	// module named.
	Jar []JarCookie `json:"-"`
	// Minted is the session the host minted while this module was answering, or
	// nil. Also not part of the record: a module neither sends it nor sees it, and
	// what it holds — the session token — is the one value M65 exists to keep on
	// this side of the sandbox.
	//
	// It is on the response rather than a second return value because the two
	// travel together to exactly one place: internal/httpx writes a cookie and then
	// writes the module's answer, in that order, and a caller that ignored this
	// field would produce a page for somebody the host had already signed in.
	//
	// A module that mints and then **fails** — traps, writes no response, answers a
	// refusal — loses this, because Route returns an error and there is no response
	// to carry it on. The session row still exists and expires on its own; the
	// visitor gets a 502 and is not signed in. That is the safe direction of the two
	// and it is stated rather than fixed: writing a session cookie alongside a
	// failure page would sign somebody in on the strength of a module that crashed.
	Minted *Minted `json:"-"`
}

Response is the HTTPResponse record, host-side, after the host has checked it.

The bounds are enforced at the moment the guest writes it — see http_response_write in hostabi.go — so a module learns its answer was refused by getting StatusInvalid from the call it made, rather than by a page that silently differs from what it asked for.

type SessionClaim

type SessionClaim struct {
	Subject       string   `json:"subject"`
	Issuer        string   `json:"issuer"`
	Email         string   `json:"email"`
	EmailVerified bool     `json:"email_verified"`
	DisplayName   string   `json:"display_name"`
	Groups        []string `json:"groups"`
}

SessionClaim is the record a module writes into `session_mint`: its assertion that somebody authenticated.

Decoded strictly (see [decodeClaim]): a field this host does not know is a module written against a contract that is not this one, and guessing at what it meant on the authentication path is the wrong direction to guess in.

type SessionContext

type SessionContext struct {
	SignedIn       bool   `json:"signed_in"`
	UserID         string `json:"user_id"`
	Email          string `json:"email"`
	DisplayName    string `json:"display_name"`
	WorkspaceID    string `json:"workspace_id"`
	OrganizationID string `json:"organization_id"`
	Role           string `json:"role"`
}

SessionContext is the SessionContext record, host-side: who is signed in on the request an add-on is answering.

Nothing here is a credential. No cookie, no token, no session identifier — D232's rule applied to the read half, and the reason the ABI's credential blocklist walks this record's field names.

type SessionMinter

type SessionMinter interface {
	MintFromAddonAssertion(ctx context.Context, in auth.AddonAssertion) (*auth.AddonMint, error)
	// LinkAddonIdentity is `identity_link`'s half. The actor is the *host's* —
	// resolved from the request's own session — and it is a parameter rather than
	// something the implementation looks up, because that is what makes "a module
	// names a subject and the host names the account" a property of the signature.
	LinkAddonIdentity(ctx context.Context, actor *auth.Identity,
		addon, issuer, subject string) error
}

SessionMinter is what this package needs from internal/auth in order to answer `session_mint`.

An interface rather than the concrete service, for the reason AddonRouter is one in internal/httpx: the tests in this package construct hosts without a database, and a host that could only be built beside a real auth service could not be tested at all. A nil minter is a host that cannot mint — which is what every unit test in this package is, and what an instance never is.

type Setting

type Setting struct {
	Name    string      `json:"name"`
	Type    SettingType `json:"type"`
	Options []string    `json:"options,omitempty"`
	Default string      `json:"default,omitempty"`

	// Origin marks a setting whose value is the origins this add-on may make
	// outbound requests to (M68.5), and it is the whole of how a destination
	// reaches the host: an origin the host will dial comes from a setting marked
	// here and filled in by the operator, and from nowhere else.
	//
	// **A flag rather than a fifth [SettingType]**, because what changes is the
	// meaning and not the input: the operator types text into the same box M68
	// already draws, and a new type would be a shape the manager has to learn to
	// render for no gain. What the flag costs is stated in [Setting.validate] —
	// such a setting is `text`, carries no default and carries no options — and
	// each of those is what keeps *the manifest declares a need, never a
	// destination* true. A default would be a host the add-on's author chose; an
	// option list would be several.
	//
	// The value an operator writes is one or more origins separated by spaces, so
	// an issuer whose token endpoint lives on a second name is two origins in one
	// field rather than a second field the add-on had to have anticipated.
	Origin bool `json:"origin,omitempty"`
}

Setting is one declared configuration value.

Options is meaningful only for SettingSelect — the owner's term was "select-with-options", so the options travel with the declaration rather than being fetched from the add-on at render time. Default is a string for every type, including toggle, because a manifest is JSON written by hand and one representation is one fewer thing for a publisher to get wrong; the validation below is what makes a toggle's default honest.

type SettingSource

type SettingSource string

SettingSource says where the value the add-on will read came from.

Three states rather than two, because "nothing has been set" and "the operator set it here" are different things to draw: the first offers an empty field, the second offers a field with a value in it and a way to clear it.

const (
	// SourceUnset means neither route has answered, so the add-on reads the
	// manifest's default or nothing at all.
	SourceUnset SettingSource = "unset"
	// SourceStored means the value came from `addon_settings` — somebody typed it
	// into the manager, and the manager can change it.
	SourceStored SettingSource = "stored"
	// SourceEnvironment means `LINKCTRL_ADDON_<NAME>_<SETTING>` is set. The field
	// is read-only and the page names the variable, for the reason the first-run
	// update-check prompt names its own (D149).
	SourceEnvironment SettingSource = "environment"
)

type SettingType

type SettingType string

SettingType is the input the host renders for a declared setting.

Four, and exactly the four the owner named when the Add-on manager's detail page took a Settings section (2026-08-18). M60 parses and stores them; M68 renders them and saves the values. Nothing here is a UI decision — it is the vocabulary M68 is allowed to meet, fixed now so an add-on published against this schema cannot describe an input the manager will not draw.

const (
	SettingText   SettingType = "text"
	SettingSecret SettingType = "secret"
	SettingSelect SettingType = "select"
	SettingToggle SettingType = "toggle"
)

type SettingView

type SettingView struct {
	Name    string        `json:"name"`
	Type    SettingType   `json:"type"`
	Options []string      `json:"options,omitempty"`
	Default string        `json:"default,omitempty"`
	Source  SettingSource `json:"source"`
	// Value is the effective value for a text, select or toggle setting, and is
	// always empty for a secret.
	Value string `json:"value,omitempty"`
	// Configured is whether anything has answered this setting — the only thing
	// said about a secret that has a value.
	Configured bool `json:"configured"`
	// EnvVar is the variable that answered it, and is empty unless Source is
	// SourceEnvironment. Named so an operator who wants to change a pinned value
	// knows what to edit.
	EnvVar string `json:"env_var,omitempty"`
	// UpdatedAt is when the stored value was last written, and is nil for every
	// other source.
	UpdatedAt *time.Time `json:"updated_at,omitempty"`

	// Origin is whether this setting names where the add-on may make outbound
	// requests (M68.5), and the page renders it differently for one reason: filling
	// it in **authorizes a server-side request from this instance** to whatever is
	// typed in it. m68.5.md's second risk is exactly that — *they name an origin and
	// thereby authorize a server-side request to it; if the page does not make that
	// consequence plain, the setting reads like a URL field*.
	//
	// It rides on the view rather than being derived from the name, because the
	// declaration is the manifest's and the page must not be guessing which of an
	// add-on's fields is the dangerous one.
	Origin bool `json:"origin,omitempty"`

	// SignIn marks the one setting on this page the host declares rather than the
	// add-on: the operator's consent to that add-on's link appearing on the
	// sign-in page (M69.5). The page renders it differently for the reason
	// [SettingView.Origin] does — turning it on changes what every visitor to this
	// instance sees before they have authenticated, which is not a consequence a
	// bare checkbox conveys.
	SignIn bool `json:"sign_in,omitempty"`
}

SettingView is one declared setting as the Add-on manager renders it.

**It never carries a secret's value**, whichever source it came from, and that is structural rather than a rule the template has to remember: the field does not exist on this type for a secret, because [Value] is populated only for the three types whose value is not a credential. `Configured` is what tells the page a secret has been set, which is the whole of what it may say about one.

[Type] is the declared type **or** the type the stored value was written under, whichever withholds. See [Host.settingViews] for why a manifest cannot demote a stored credential to a text box by re-declaring it.

func (SettingView) Editable

func (v SettingView) Editable() bool

Editable reports whether the manager may write this setting. False for one the environment answers, which the page renders as a sentence rather than a field.

func (SettingView) IsOrigin

func (v SettingView) IsOrigin() bool

IsOrigin reports whether this setting names where the add-on may reach. See SettingView.IsText for why these are methods.

func (SettingView) IsSecret

func (v SettingView) IsSecret() bool

IsSecret reports whether this setting is a credential. See SettingView.IsText.

func (SettingView) IsSelect

func (v SettingView) IsSelect() bool

IsSelect reports whether this setting is a fixed choice. See SettingView.IsText.

func (SettingView) IsSignIn

func (v SettingView) IsSignIn() bool

IsSignIn reports whether this setting is the operator's consent to an add-on's sign-in link. See SettingView.IsText for why these are methods.

func (SettingView) IsText

func (v SettingView) IsText() bool

IsText reports whether this setting is a plain text field.

It and its three neighbours below are the type predicates the manager's template branches on. Methods rather than a comparison in the template, because SettingType is a named string type and `eq .Type "toggle"` compares a SettingType with an untyped constant — which text/template resolves at render time and gets wrong quietly. A method is checked by the compiler against the same vocabulary Manifest.Validate enforces.

func (SettingView) IsToggle

func (v SettingView) IsToggle() bool

IsToggle reports whether this setting is a boolean. See SettingView.IsText.

func (SettingView) On

func (v SettingView) On() bool

On is whether a toggle's box is ticked. False for every other type, so the template never has to ask twice.

type SignInLink struct {
	Addon string
	Label string
	Href  string
}

SignInLink is one add-on's offer on the sign-in page.

[Href] is the host's composition and never the add-on's string; [Label] is the add-on's string and is hostile input, rendered through html/template like every other value on every other page. [Addon] is the module it came from, which the page does not draw — it is what a log line or a test names.

type URLInstallRequest

type URLInstallRequest struct {
	// URL is the bundle's address. https only, checked by the same
	// [checkFetchRequest] an add-on's own fetch passes through.
	URL string
	// SHA256 is what the fetched bundle must hash to, lowercase hex, supplied by
	// the operator and never read out of anything this host fetched.
	SHA256 string
}

URLInstallRequest is what an operator typed: where the bundle is, and what it must hash to.

**Two fields and they are inseparable.** The digest is not optional and there is no shape of this request without one — an install that fetched whatever the URL happened to serve would be exactly the request forgery m67.md refused, with the response executed. The surfaces put the two inputs side by side for the same reason.

Directories

Path Synopsis
abi
Package abi is the add-on ABI: the complete set of functions a module may import from this host, the version that set is published under, and the rule for deciding whether a change to it is breaking.
Package abi is the add-on ABI: the complete set of functions a module may import from this host, the version that set is published under, and the rule for deciding whether a change to it is breaking.
gen command
Command gen writes every generated face of the add-on ABI from the one place the ABI is authored, internal/addon/abi.
Command gen writes every generated face of the add-on ABI from the one place the ABI is authored, internal/addon/abi.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL