Hosted Projects & the Agent Plane

A how-to, not a spec. Normative detail lives in services.md, mcp.md, and bundle-format.md § References.

A hosted project lives in your arkaik account rather than in one browser. It is the same graph, the same format, and the same tools — but the app and a coding agent in any repository can both read and write it, and pull requests can move its acceptances as work ships.

Local-first projects are unchanged. Nothing here is required to use arkaik.

The two links (read this first)

The word "link" means two different things, and you generally want both. They are independent — either works without the other.

WhereWhat it connects
arkaik linkyour terminal, in a code repothat repo → a hosted project, so an agent working there can read and edit the map
Repos buttonthe app, on a hosted project carda repo — or one folder of it — → this project's acceptances, so pull requests there can move statuses

The first is the agent plane. The second is the PR automation. Set up whichever you need.

1. Create a token

/settings/tokens → Create token. It is shown once; it is stored hashed and cannot be recovered.

export ARKAIK_TOKEN=ark_...

Scopes default to graph:read + graph:write. Token management itself is session-only, so a leaked token can never mint another or widen its own scopes.

A token for CI: quality:append

There is a scope you have to ask for — it is never a default. quality:append can append one kind of event to one route: a tripped Kritik signal. It cannot read your graph, cannot read your findings, and cannot decide one. Send it a quality.finding.resolved and you get a 403 naming the scope you did not take.

That narrowness is the whole point. This is the credential you can put in a public repository's Actions secrets, because there is nothing in it worth stealing beyond the ability to write an observation you would have written anyway. When your nightly harness goes red, the ledger that raised the finding hears about it:

- name: Record the trip
  if: failure()
  run: |
    curl -sS -X POST "$ARKAIK_URL/api/graph/projects/$PROJECT_ID/quality/events" \
      -H "Authorization: Bearer $ARKAIK_TOKEN" \
      -H "Content-Type: application/json" \
      -d "$(jq -n --arg c "$GITHUB_SHA" --arg d "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
        '{events:[{type:"quality.signal.tripped",criterion_id:"SEC-supabase-01",surface:"supabase",signal:"contract harnesses pass against the linked project",commit:$c,detail:$d}]}')"
  env:
    ARKAIK_TOKEN: ${{ secrets.ARKAIK_QUALITY_TOKEN }}

commit is required here — a workflow already knows it, so this route asks for it. The run URL rides in the free-form detail.

A trip is not a finding. Nothing in CI mints findings; a trip is cheap, frequent, allowed to be wrong, and the prompt to go look.

A token for deployments: release:append

A merge is not a release. ref_policy carries an acceptance to releasing when its pull request merges; what carries it to live is the deployment — and the signals for that come from CI, which is exactly where a broad credential must not go. release:append is quality:append's sibling: one route, one kind of write, and you have to ask for it when minting.

What it can do: say this acceptance reached live on this platform. What it cannot: read your graph, move a status anywhere but live, touch anything but an acceptance, or skip the platform. platform is required: a deploy is always a deploy of something, and saying which keeps one release from marking the others shipped. A platform the acceptance does not list is refused and reported, never guessed. An archived acceptance, or a platform whose own entry is archived, is refused too: a deploy does not bring back what was deliberately dropped. An acceptance already live on that platform is skipped with a 200, so a re-run job is harmless. A batch is all-or-nothing: one refused entry writes nothing, and the 422 names each entry's index and reason. Filter the list to acceptances that ship on this platform before you send it.

The token is minted for an owner, not a project, so it reaches every project that owner has — and it can mark an acceptance live from any status, not only releasing. A wrong live is a visible arkaik-ci event in the journal that a human can revert; keep the token where only your release workflows read it. What it learns is limited to what a refusal says about the ids it names (that an id exists, its species, its platforms).

curl -sS --fail-with-body -X POST "$ARKAIK_URL/api/graph/projects/$PROJECT_ID/live" \
  -H "Authorization: Bearer $ARKAIK_RELEASE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"entries":[{"node_id":"AC-guest-checkout","platform":"ios","detail":"App Store 2.4.1 (build 318)"}]}'

--fail-with-body makes the step fail on a 4xx instead of reporting green while nothing went live — this call is the whole point of the step.

You rarely need the curl. The CLI wraps it — arkaik live --platform web --detail "$URL" AC-guest-checkout — and derives the ids for you from the pull requests a deploy shipped when you hand it a --mentions file (see arkaik live --help). And the mark-live reusable workflow does all of it from a deployment_status event: it finds the previous successful deploy of the same environment, collects the pull requests merged in between, reads them with the same AC-id@platform rules the GitHub App applies on merge, and calls the door. One file in your repository:

name: mark-live
on:
  deployment_status:
  workflow_dispatch:
permissions:
  contents: read
  deployments: read
  pull-requests: read
jobs:
  web:
    if: github.event_name == 'workflow_dispatch' || (github.event.deployment_status.state == 'success' && github.event.deployment.environment == 'Production – my-app')
    uses: alexisbohns/arkaik/.github/workflows/mark-live.yml@main
    with:
      platform: web
      paths: apps/web
      sha: ${{ github.event.deployment.sha || github.sha }}
      environment: ${{ github.event.deployment.environment || 'Production – my-app' }}
      detail: ${{ github.event.deployment_status.environment_url || '' }}
    secrets:
      ARKAIK_RELEASE_TOKEN: ${{ secrets.ARKAIK_RELEASE_TOKEN }}

paths is for monorepos: a bare AC-x mention counts only when that pull request touched a file under one of the prefixes, so an iOS-only pull request that forgot @ios does not mark web live. Leave it out in a single-platform repository. On the first run there is no previous deploy to measure from, and the workflow marks nothing rather than guessing — pass explicit ids once, or let the next deploy be the first real one. The workflow builds the CLI from arkaik@main, so there is nothing to install or pin. The permissions block is required: a called workflow can only use what the caller grants, and the default token grants neither deployments nor pull requests. The || fallbacks are what let a hand-run workflow_dispatch work: with no deployment event to read, the environment name and the commit come from them. Dispatch when the branch head is what's deployed: a hand run measures up to that head, not to a specific deploy.

When a run goes red — a refused explicit claim, the door unreachable — that deploy's acceptances are not marked, and the next deploy measures from this one, not from the last successful mark. Watch the Actions tab: fix the graph or the token, then Re-run failed jobs (it reuses the same event, so the same range), or dispatch with explicit ids. An acceptance named bare that the project does not list for this platform is dropped with a warning instead of failing the run, the way the GitHub App treats an inferred scope.

detail is optional, and the one extra worth sending: the deployment URL, the store build number — whatever lets someone reading the journal later see why this went live. It lands on the node.status_changed event, written by arkaik-ci, beside the platform, and the journal shows it under the move.

Where the call goes depends on the platform, because each one has a different moment:

platformthe momentwhere the call goes
webthe production deploy succeededthe mark-live workflow on deployment_status, as above
androidthe release reached the production track and Play review is donethe same workflow with explicit ids from the step that promotes to production — or a scheduled poll of the Play Developer API track status that computes them, when promotion happens by hand
iosthe version is released on the store — a manual or phased release moves this momenta scheduled workflow that asks App Store Connect for the version's state and, when it reads as released, calls the same workflow with explicit ids (READY_FOR_SALE in the classic API; newer API versions name the state differently, so check yours) — approval is asynchronous, and no repository event marks it

Which acceptances a deploy carried is the part no API can know for you. For web the workflow reads them off the pull requests merged since the last deploy; for the platforms where it cannot see that moment, work the ids out yourself and pass them as ids. The door accepts up to fifty entries per call.

A token for screenshots: screenshot:append

A screenshot is a fact about a commit, and the job that renders it is where it should be recorded from. screenshot:append is the third append-only scope: one route, POST …/screenshots, one kind of write. What it can do: say here is a screenshot of this view or acceptance on this platform, from this commit. What it cannot: read the graph, move a status, touch a flow or a data model, or fill a platform the node does not list. A leaked token can post a picture.

Each entry names the node, the platform, an https URL you host, the commit and its order — the commit's height on your default branch (git rev-list --count <sha>). The order is what keeps a re-run or a rebase that lands late from overwriting a newer shot: an entry below the slot's watermark is a stale_order skip, an unchanged URL an unchanged skip, and both are a 200. The workflow refuses a commit that is not on the default branch, because the order is only comparable as that branch's height; call it from a push or deploy of the default branch, not from a pull request. A refused entry (unknown_node, not_shot_bearing, platform_not_applicable, invalid_url) refuses the whole batch with a 422 naming its index. A shot a person records in the UI always lands and becomes current; the next CI run after it replaces it, which is the point.

curl -sS --fail-with-body -X POST "$ARKAIK_URL/api/graph/projects/$PROJECT_ID/screenshots" \
  -H "Authorization: Bearer $ARKAIK_SCREENSHOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"entries":[{"node_id":"V-home","platform":"ios","url":"https://shots.example/'"$SHA"'/ios/V-home.png","commit":"'"$SHA"'","order":412,"detail":"HomeTests.default"}]}'

The CLI wraps it — arkaik shot --manifest shots.json --commit $SHA --order $ORDER — and the record-screenshots reusable workflow does it from a manifest your render job uploads as an artifact:

jobs:
  render:
    # … run your screenshot tests, upload the PNGs to your bucket,
    # write shots.json as [{ node_id, platform, url, detail? }] …
    steps:
      - uses: actions/upload-artifact@v4
        with: { name: shots, path: shots.json }
  record:
    needs: render
    uses: alexisbohns/arkaik/.github/workflows/record-screenshots.yml@main
    with:
      manifest_artifact: shots
    secrets:
      ARKAIK_SCREENSHOT_TOKEN: ${{ secrets.ARKAIK_SCREENSHOT_TOKEN }}

Which test feeds which node is your naming convention — a test whose name carries the AC-… or V-… id is the simplest — and the bytes stay with you: arkaik stores URLs, not images.

2. Get a hosted project

On /projects, either create a project while signed in, or take a browser-held one and click Move to account.

Moving is a copy: the local original stays where it is and the hosted copy gets a new server-owned id (prj_…). Nothing is lost if you change your mind.

Hosted projects carry an In your account badge.

3. Point an agent at it

In the repository where you write code:

npx arkaik link --list                 # projects this token can reach
npx arkaik link --project prj_xxxxx    # writes docs/arkaik/arkaik.json

docs/arkaik/arkaik.json holds the project id and origin. It is safe to commit; the token is only ever read from $ARKAIK_TOKEN, because a credential in a repo file is a credential in a git history.

Then point any MCP host at the server:

npx -y arkaik-mcp        # picks up the link file automatically

The tool catalog is identical to repo-bundle mode — list_nodes, get_node, create_node and the rest behave the same whether the graph is a file or an account. Resolution order, if you need to override: --bundle always wins; otherwise --project → $ARKAIK_PROJECT → the link file.

A linked repo with no token exits non-zero rather than quietly serving a stale local bundle — a silent fallback is the failure an agent cannot notice.

Kritik on a hosted project

A hosted project has no docs/quality/ directory. Its Kritik state is the quality section that arkaik restore stored, plus the journal events that arrived since. The server folds those events in on every read. Over MCP, kritik_matrix, kritik_findings, kritik_regressions, kritik_issue, kritik_trend and kritik_scope read it. kritik_resolve_finding and kritik_accept_finding decide a finding. kritik_score and kritik_matrix record=true run a scoped re-audit. kritik_open_finding, kritik_signals and kritik_trip_signal stay repo-only, and so does a comprehensive audit.

Every hosted Kritik write goes to one route, POST /api/graph/projects/{id}/quality/events, and it takes exactly five event types:

TypeScopeWritten by
quality.finding.resolvedgraph:writekritik_resolve_finding, and the GitHub App on merge
quality.finding.acceptedgraph:writekritik_accept_finding
quality.signal.trippedquality:append or graph:writea CI job (above)
quality.assessment.scoredgraph:writehosted kritik_score
quality.audit.completed, scopedgraph:writehosted kritik_matrix record=true

A batch is all-or-nothing. A refused batch writes nothing, and each refusal names the entry's index, a reason, and usually a detail saying what to do instead.

The scoped re-audit loop. Closing findings doesn't move a score; only a re-score does. The session stays hosted: the agent needs the product's code at hand to read, at the commit that holds the fixes, not a repo-mode session.

  1. kritik_scope lists the cells the resolved findings made stale. Its open_audit names a scoped re-audit already in progress (or is null), and a cell that audit has scored reads rescored: true. It is picked the way the server picks the audit an id-less score continues: the last unrecorded audit, in journal order, scored from the current since. The server checks every score against the default scope (measured from the newest recorded audit, widened), so kritik_scope's since and widen=false change what the read shows, not what kritik_score accepts.
  2. kritik_score with scope=true and commit=<sha> re-scores each listed cell, with evidence, including a widened cell that didn't move: score it at its current level, and say so in the evidence. Leave audit_id off: the server names the audit (<YYYY-MM>-scoped, continuing the open one) and returns it.
  3. kritik_scope again: every cell should read rescored: true.
  4. kritik_matrix with record=true, audit_id=<open_audit.audit_id> (the id kritik_score returned, never the scope's since) and commit=<sha> records it. The server computes the merged scores and counts itself; a caller-supplied scores or counts is refused.

Hosted scoring is scoped-only. A cell the scope doesn't list is refused (out_of_scope), and there is no flag to score it anyway. That is a workflow guard that keeps a re-audit from drifting into a full one. It is not an access control: any graph:write caller can resolve a finding, and that widens the scope. A comprehensive audit still runs in the repo and lands through arkaik restore.

Nothing lands in docs/quality/. The journal events are the whole record, so a public repository can re-audit without committing a list of its own defects.

The one-time baseline. A project whose audit arrived by arkaik restore has the audit but no recorded reading of it, and a scope is measured from a recorded reading. So until one exists, the reads build an implicit baseline from the stored section (packages/schema/src/quality-baseline.ts):

  • It is dated where the audit was taken, and never later than just before the first finding decision. So every fix since the audit stays in the window.
  • It is scored the way the audit saw it: a finding decided since then counts as open again.

kritik_scope, kritik_trend and the app's Quality page use this baseline. The trend shows it as a first row flagged baseline. (arkaik kritik trend marks baseline and scoped rows the same way, in the journal sidecar it reads.) The first hosted score writes that same reading into the journal for real: a quality.audit.completed backdated to the baseline's time and flagged baseline: true. kritik_score returns it and says so. After that the synthesis switches off.

Other refusal reasons: no_baseline (nothing to re-score from), invalid_assessment (an unknown or retired criterion, a surface the profile doesn't declare, cross-surface, or a criterion that doesn't apply there), audit_id_conflict (the audit named can't take the score), not_scored (recording an audit with no scores), already_recorded, and scope_mismatch (the audit was scored against a scope that has since moved; score without audit_id to open a new one). A malformed entry never gets that far: a level outside 0–4, empty evidence, a completion without scope: true, or one carrying scores/counts is a plain 400. If a recording's response is lost and you retry it with the same audit_id, kritik_matrix finds that scoped re-audit's reading and returns it as a success. already_recorded on any other audit, such as the scope's since, is a real error.

This is the whole agent setup. The rest of this page is the PR automation, which is optional.

4. Set up the GitHub App (once per deployment)

Only needed for PR-driven status changes, and only once for your whole account.

  1. New GitHub App
    • Webhook URL: https://<your-origin>/api/github/webhook
    • Secret: generate one (openssl rand -hex 32) — required
    • Repository permissions → Pull requests: Read-only
    • Subscribe to events → Pull request
    • Leave Callback URL, Setup URL, and device flow blank; they are unused.
  2. Set GITHUB_WEBHOOK_SECRET to the same value in your host's environment, and redeploy — most platforms do not apply env changes to a running deployment.
  3. Install the App on the repositories you care about.

A private key, only if you use path-scoped links

Everything above resolves from the delivery payload, so a deployment whose repository links each cover a whole repository never calls the GitHub API back and needs no key. Path-scoped links do: deciding whether a pull request belongs to apps/ios or apps/webapp means reading which files it changed, and that list is not in the payload. See Monorepos.

If you want them: Generate a private key in the App's settings, then set both GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY (the .pem contents, verbatim, -----BEGIN and -----END lines included) and redeploy.

  • It has to be that key, and it has to be RSA. A GitHub App JWT is RS256, so an Ed25519 or EC key — what a TLS or SSH key pasted into the wrong secret looks like — cannot sign one. Both parse as valid PEM files, so arkaik checks the key type and reports it by name before signing anything, rather than crashing mid-delivery on one and sending a signature GitHub answers 401 to on the other.

  • No permission change and no reinstall. The pull-request files endpoint, GET /repos/{owner}/{repo}/pulls/{number}/files, is a sub-resource of a pull request and needs Pull requests: Read — the permission step 1 already asks for. arkaik reads only each entry's filename and previous_filename, never a blob, so no Contents permission is involved. It does not take that on trust either: the token exchange asks for pull_requests: read explicitly and checks the response granted it, so a misconfigured App is reported by name.

  • The App has to be installed, not merely configured as a repository webhook — the changed-files call is authenticated as the installation, whose id arrives in the delivery. A plain repository webhook carries none, and the delivery says so instead of guessing a platform.

  • If your host can only store single-line secrets, literal \n escapes in the key are accepted and converted. A base64-encoded copy of the whole file is not, and is refused with a message saying so rather than a crypto stack trace.

  • Missing key, missing App id, or no installation id are all deterministic: the delivery reports the exact variable to set and moves nothing. No retry can set an environment variable, so it is not retried. A GitHub 5xx, a rate limit or a network failure is the opposite — the delivery fails with a 500 so GitHub's redelivery can redo it, and no partial write is left behind, because the file list is read before anything is written.

Checking it works

An unsigned request to the endpoint is a precise diagnostic:

curl -i -X POST https://<your-origin>/api/github/webhook \
  -H 'content-type: application/json' -d '{}'
ResponseMeaning
503 webhook_not_configuredthe secret is not set (or you have not redeployed)
401 invalid_signaturecorrect — the secret is live and unsigned requests are refused

Then in the App's Advanced → Recent Deliveries, redeliver the ping. A 202 proves the signature matched, not merely that the endpoint is reachable — GitHub signs pings too.

Verification refuses everything when no secret is configured, rather than degrading to accepting unauthenticated writes.

5. Link repositories to the project

On the hosted project's card → Repos → enter owner/name, leave the path box empty, and choose the platform that repository builds for → Link.

The path box is for monorepos, and only for them: a link is identified by (repository, path), so one repository can be linked several times — apps/ios as iOS, apps/webapp as Web. An empty path is the whole repository, which is what every link made before this existed already means. See Monorepos.

The platform is the point: a PR merged in an iOS repository marks only iOS shipped, with no per-PR annotation and no way to forget. The remaining platforms then surface as a genuine parity gap on /acceptances, rather than false parity. Choose All platforms for a repository that genuinely serves every platform at once: its PRs then move the acceptance's base status, which marks every platform that has no per-platform status of its own as shipped — unless a PR names a platform itself (see step 7). A platform already pinned in platformStatuses keeps what it was pinned to; the base status is only ever the fallback, which is exactly why moving it reaches so far.

If a PR in that iOS repository mentions an acceptance that does not list iOS — a web-only acceptance, say — the delivery attaches no ref and promotes nothing, and says so in its warnings. (If that acceptance already carries a ref for the pull request, the ref keeps being mirrored — a stale status helps nobody — but that delivery promotes nothing from it. See the refusal note in step 7 for what "that delivery" does and does not cover.) It does not fall back to an unscoped ref: an unscoped ref moves the base status, and would mark that acceptance shipped on web, which is the opposite of what linking the repo to iOS asked for. A missing claim is recoverable; a wrong one looks like success.

"All platforms" is a claim, not a shrug. The base status is what every platform without its own entry falls back to, so moving it says every one of those shipped — and when nothing is pinned, that is all of them and /acceptances shows no parity gap. If a repo does not really ship all platforms together, link each app's folder to its own platform — see Monorepos.

6. Opt the project in

Promotion is opt-in, per project. Without it, a PR's status is mirrored onto the acceptance's ref but no status moves — that is the format's default (bundle-format.md § References), and it is deliberate: automatic promotion should be a recorded decision, not an implicit behaviour of the tooling.

There is no UI for this yet. Open the project → Journey map → Raw, and add ref_policy to the project's metadata:

"project": {
  "id": "…",
  "title": "…",
  "metadata": { "ref_policy": true }
}

true selects the defaults:

PR stateAcceptance status
opened / reopeneddevelopment
mergedreleasing
closed without mergingunchanged

A merge is not a release: the default stops at releasing, and live is what a deployment says — see A token for deployments. To choose your own mapping, give an object instead. null means "recognised, moves nothing"; this is the map that makes a merge mean live:

"ref_policy": { "github-pr": { "open": "development", "merged": "live", "closed": null } }

7. Ship something

Open a pull request in a linked repository whose title or body mentions an acceptance id, for example:

Implements AC-guest-checkout
  • on open → that acceptance moves to development
  • on merge → releasing, on the platform that repository builds for — or, in a monorepo, the platform of the folder the PR touched
  • on deploy → live, when your deploy or release workflow says so through the release:append door

Naming the platform in the mention

Add @web, @ios or @android to say what shipped, regardless of what the repository link declares or which folder the PR touched:

Implements AC-guest-checkout@ios
  • an explicit @platform always wins. The whole precedence, once: the @platform in the mention, then the path-scoped link the pull request's changed files landed in, then the repository's own link. A bare mention in an iOS-linked repo still means iOS, but @web there means web;
  • one PR can name several — AC-guest-checkout@ios and AC-guest-checkout@android moves both platforms and leaves the third as a genuine parity gap;
  • writing both AC-guest-checkout and AC-guest-checkout@ios in one PR means iOS only — the explicit scope absorbs the bare mention rather than also moving the base status;
  • an unknown platform is reported, not guessed — AC-guest-checkout@windows moves nothing at all and says so in the delivery response, because asking for a platform and quietly getting the repo default is worse than doing nothing. The suffix has to match a platform name exactly: @android-tv, @android_tv, @ios.tv, @ios/ipad and @ios2 are all reported as written, never read as android or ios. Ordinary prose around the mention is fine — AC-x@ios., (AC-x@ios) and `AC-x@ios` all mean iOS;
  • \@ works too — writing AC-guest-checkout\@ios to stop GitHub rendering @ios as a user mention still scopes to iOS, rather than silently becoming an unscoped mention;
  • naming no platform is the biggest claim, not the smallest — an unscoped mention moves the acceptance's base status, which every platform without its own entry falls back to. On a three-platform acceptance with nothing pinned that moves all three at once. On a partly shipped one it can be worse: under a merge-means-live policy, {web: "live"} with a base of backlog is a real parity gap, and moving the base to live makes iOS and Android inherit it, so the gap disappears; under the default, iOS and Android are still marked releasing when nobody named them. Either way the delivery response names the platforms it is about to mark, one line per acceptance.

An agent can also attach the ref explicitly with update_node rather than relying on a mention.

Guards that keep this honest:

  • archived acceptances are never resurrected by a stale PR;
  • live is kept, not re-earned — a follow-up pull request on an acceptance that already reads live on that platform attaches its ref and moves nothing. Users still have the feature; only a human edit moves a status off live;
  • an acceptance already at the target status is skipped, so re-runs and redeliveries are no-ops;
  • a platform that was asked for and cannot be honoured is refused, never downgraded — this is the rule the other platform guards are instances of. An unscoped ref moves the base status, which marks every platform without a per-platform status of its own shipped, so falling back to one whenever a scope cannot be resolved would make the largest possible claim at the moment arkaik is least sure. It does not:
    • a @platform you asked for explicitly stays on the ref, and the promotion is reported and refused, rather than moving the base status;
    • a repository link naming a platform the acceptance does not list attaches no ref at all and promotes nothing, and the warnings line names both the link's platform and the acceptance's actual ones;
    • a path-scoped link is a standing request to scope every pull request, so when that request cannot be answered — the pull request touched none of the linked paths and no whole-repository link exists, or the changed files could not be read at all — nothing is attached and nothing is promoted. The warnings line quotes every configured prefix, so a typo'd apps/i0s is visible in the one place you can see it;
    • a refusal covers promotion, not just attachment. If the acceptance already carries a ref for that pull request, the ref keeps being mirrored — a body edit must not freeze it at a stale status — but that delivery promotes nothing from it, on the first delivery or on any redelivery. It is scoped to the delivery, not a quarantine: the ref stays attached and stays promotable, so arkaik sync --promote — which computes promotions over the whole bundle, with no notion of a delivery — can still move a status from it. If you do not want that, remove the ref;
  • an unknown @platform suffix is reported, never treated as a bare mention — and if the same PR also mentions that acceptance bare (AC-x in the title, AC-x@ios-tablet in the body), the bare mention is not used as the typo's fallback either. The refusal belongs to the acceptance id, not to what else the PR happens to say about it: a PR whose only reference is AC-x@ios-tablet refuses in exactly the same way, rather than falling through to the repository link and promoting whatever it declares. Nothing is attached, nothing moves, and any ref the PR already left on that acceptance is not promoted from on that delivery. Fix the suffix and edit the PR: an edit re-delivers;
  • every promotion lands as a normal node.status_changed journal event with github-app as the actor — the history says what acted.

If nothing happens, Advanced → Recent Deliveries shows the response body, which names what was applied, why it was skipped, and any warnings — that is where an unknown platform suffix is quoted back to you, where an unscoped promotion tells you which platforms it is about to mark shipped, and where a refused scope names what it refused and why.

The same response carries a quality array, one entry per finding the pull request said something about. resolved means an event was appended; unchanged means the finding was already decided; unknown means a Closes F-… named an id no linked project holds; and mentioned means the pull request named a still-open finding without closing it — no verb at all, a verb that landed on a different line than the id, or a Closes F-… written in the title rather than the body. Nothing was written for it:

{ "status": "mentioned", "findingId": "F-2026-08-PLT-ios-02",
  "hint": "named but not closed — write `Closes F-2026-08-PLT-ios-02` in the PR body, verb and id on one line with nothing but spaces or a colon between them" }

A resolved entry may also carry a warning. A surface can declare where it lives in the repo — path in the profile, apps/ios and the like — and a resolution whose pull request touched no file under that path says so:

{ "status": "resolved", "findingId": "F-2026-08-PLT-ios-02", "eventId": "…",
  "warning": "resolved F-2026-08-PLT-ios-02, but this pull request changed no file under `apps/ios` (surface `ios`)" }

It is a second opinion, never a refusal: the finding is resolved either way. path is optional, and a real fix can legitimately live in a shared package, so gating the closure on this would fail in the opposite direction — findings that were genuinely fixed would silently stay open. It is also only ever raised against a complete file list: when the changed-file list came back incomplete or unreadable, there is no warning at all, because a missing file could invent a mismatch that was never really there.

A finding closes only on Closes/Fixes/Resolves before its id, in the body, on the same line, with nothing but spaces or a colon between them — a wrapped verb or one left in the title reports instead of closing. That is deliberate: naming a finding in a follow-up table used to close it, and a PR that shipped one of six closed all six.

When nothing above applies to any linked project, quality holds a single entry naming which of three silences it was, and they are not interchangeable: no_mentions means the pull request named no finding at all, decided before any project is read; nothing_to_do means a linked project holds what it named, and every one was already resolved, accepted or refuted; and no_quality_data means no linked project holds what the PR named, at all — narrower than it sounds, and not the answer for a project that holds the finding and simply had nothing left to do about it.

A refusal plans no promotion for that acceptance, and attaches no new ref. It is not always zero ops: if the acceptance already carries a ref for that pull request, the mirror refresh is still planned, so applied can be non-zero for a delivery that moved no status at all. When the refused acceptance is the only thing the pull request named and it carried no ref yet, the outcome reads

{ "applied": 0, "skipped": "an acceptance was named, but no platform scope could be honoured", "warnings": ["…"] }

When the same pull request also moved another acceptance, applied is non-zero and there is no skipped at all — the refusal then shows up only in warnings, beside a delivery that otherwise looks like a plain success. So warnings is the field to read, not skipped: a non-empty warnings means something was not honoured whatever applied says.

The first thing to check there is the repository link itself: a delivery whose repository no project has linked answers

{ "status": "ok", "outcomes": [], "skipped": "no project has linked acme/webapp" }

which is almost always a typo in Repos, or a link that was never made.

Federation feed (optional)

A hosted project can serve its journal as a pollen feed — the transport the Ariko federation reads (spec: services.md § Pollen Feed). In the project's settings, set a plant slug under Federation; the feed then answers at /api/graph/projects/{id}/pollen for any token with graph:read. Like every other hosted read it answers conditionally: send the ETag of the last page back as If-None-Match and a poll that finds nothing new is a bodiless 304 that reads neither the bundle nor the journal.

While you're there: with the GitHub App installed, a merged PR whose body carries a ## Lab Note section also lands that note in the project's journal as a deliverable.shipped event — which the feed serves as a shipped envelope, French adaptation included. No note, no event; a malformed note is reported in the delivery response and never blocks acceptance promotion.

The deliverable records more than the note. The same delivery already knows which acceptances the PR named and which platform it resolved to, so it writes both onto the event: node_ids for the acceptances the project actually holds (an id nothing answers to is dropped, never stored), and platform when the delivery resolves to exactly one — the @platform in a mention, else the path-scoped link the changed files landed in, else the repository link. Those two fields are what the changelog renders as a deliverable's Touched list and its platform chip; a PR that spans two platforms is left unscoped rather than shown as one of them. A repository linked All platforms names none, so its deliverables carry no chip.

And the note can name the rest. A mention only ever names an acceptance, so a top-level nodes: key in the Lab Note is how a change says it touched a view, a flow, an endpoint or a data model:

en:
  title: "Your photos, right where you start"
  summary: "Recording a pebble now opens straight onto your photo library."
nodes: [V-record-photo-step, F-record-pebble-flow, AC-record-pebble-flow]

Declared ids lead, in the order you wrote them; acceptances the body mentions follow. Every id is checked against the project's graph — one nothing answers to is left off the deliverable and named in the delivery response, so a typo is visible rather than silently swallowed. A malformed nodes: value never costs the note: unusable entries are dropped one at a time and the entry still ships. Other Lab Note pipelines ignore the key, as they ignore any top-level key they do not know.

Monorepos

A single repository that builds several platforms — apps/ios, apps/webapp, apps/android under one repo — is linked once per app, with the folder that app lives in:

RepositoryPathPlatform
acme/pbblsapps/iosiOS
acme/pbblsapps/webappWeb
acme/pbblsapps/androidAndroid

Then a PR that mentions an acceptance and touches apps/ios/… marks iOS shipped, with no suffix to write and nothing to remember. A PR touching both apps/ios and apps/webapp marks both.

And it follows the PR as it changes. The folders a pull request touches are edited while it is open, so what the paths decided last time is not evidence this time. If a PR that touched both apps drops its webapp commits before merging, the web ref arkaik attached earlier is frozen: nothing about the pull request justifies it any more, so the delivery stops refreshing it and promotes nothing from it. It is not deleted — the acceptance still shows a ref recording that this PR once touched web, at the status it was last honestly mirrored to. It simply stops moving. The delivery response says which scope froze and why.

The exact rule, because the edges matter. For an acceptance the pull request currently names, a ref is mirrored and promoted from while any live source still names its platform: an @platform in the PR's current title or body, the path-scoped links its current changed files land in, or — only when no path-scoped link matched — the repository link. (Once a path-scoped link has matched, the whole-repository link is not consulted: it is the fallback for "anything I did not scope", and these files were scoped.) A platform no live source names freezes. Four consequences worth knowing:

  • freezing is not deletion, and it is not a quarantine. A frozen ref stops being carried forward — most importantly, it is never upgraded to merged — so it cannot become a standing promotion that arkaik sync --promote fires later. But if an earlier, truthful delivery had already mirrored it to a promotable state, it stays promotable out of band: freezing stops a ref moving, it does not undo a state already written. If you do not want that, remove the ref;
  • a @platform you edited out of the body is withdrawn. arkaik does not record which source attached a ref, so a web ref you asked for by hand is indistinguishable from one inferred from a file list — and once the body stops saying @web and nothing else covers web, it freezes like any other. Editing the PR is how you withdraw a scope as well as how you add one; writing @web back starts it moving again on the next delivery;
  • nothing is ever frozen on a partial file list (see the truncation and time-budget notes below). A list arkaik could not finish reading can show a platform is there and can never show it is gone, so such a delivery mirrors and promotes exactly as it would have before path-scoped links existed. Incomplete evidence never narrows anything;
  • an acceptance the PR stopped naming altogether is left alone. Its refs keep being mirrored and keep promoting; a body edit must not undo a promotion that already landed.

This needs the App's private key (step 4); whole-repository links do not, and are unaffected by it being absent.

How a path is matched:

  • paths are repository-root-relative, as GitHub writes them. /apps/ios/, ./apps/ios and apps//ios are all normalised to apps/ios when saved, so one folder is one link and not three; a .. segment is refused outright;
  • whole folders only. apps/ios matches apps/ios/main.swift and the file apps/ios itself, and never apps/ios-legacy/main.swift or apps/ios2/x.ts;
  • case-sensitively, because git paths are (repository names are not, and are lowercased for you) — Apps/iOS is a different path from apps/ios, and a link written the wrong way round matches nothing at all;
  • every changed file counts, including deleted ones and both halves of a rename: moving a file out of apps/ios is a change to apps/ios;
  • the most specific link wins per file. With both apps → Web and apps/ios → iOS, a file under apps/ios is iOS only. Adding a more specific link can therefore only ever narrow a claim, which is what refining a configuration should do;
  • a path-scoped link set to All platforms matches without naming a platform — how you say "changes under packages/shared ship everywhere" — so precedence falls through to the whole-repository link behind it, or to an unscoped ref if there is none. Re-read the box in step 5 before choosing it. If the only links a PR's files landed in are ones like this and the file list was partial, the delivery refuses instead of falling through: a page arkaik never read could well land in a link that does name a platform, and answering that with an unscoped ref is the largest claim in the system made from a blind spot;

What happens when the paths do not decide — precedence is mention, then path, then link:

  • a @platform in the PR still wins, and is now an override rather than the main event: AC-guest-checkout@web scopes to web whatever folder was touched;
  • anything outside every path falls back to a link for the same repository with an empty path, if you made one. That link is your stated answer to "everything I did not scope is this" — a root-level release PR, say;
  • with no such fallback link, nothing moves, and the delivery response says which paths are configured and that none of them matched. It does not borrow a platform from anywhere: an unscoped ref would move the base status and mark every platform shipped, which is the largest claim in the system offered in answer to "I do not know";
  • the same applies when the changed files cannot be read — no private key, no App installation, a revoked installation. That is reported with the exact variable or setting to fix, and no retry happens, because none would help. Transient failures (a GitHub 5xx, a rate limit, a dropped connection) are retried by GitHub instead, and nothing is written until the file list is in hand;
  • a PR that changes more files than GitHub will list (its cap is 3000) is matched against the partial list. A platform found that way is real — a missing file can only remove a match, never invent one — so the delivery proceeds and warns. If nothing matched in a partial list, that is not evidence of anything and the delivery refuses instead; and nothing is frozen from a partial list either. The same "partial list" treatment applies if reading the pages takes too long, or if GitHub returns an entry arkaik cannot read. The delivery response names which of the three happened, rather than blaming the file cap for all of them;
  • the time budget bounds the file-list read, and only that. Resolving the changed files — the token exchange and every page — has one 9-second budget, and a page is started only if a full request timeout still fits inside it, so that part cannot overrun a 10-second serverless limit. The database writes that follow are not inside it, so the function as a whole still can run long; what the budget buys is that a slow GitHub is not the thing that does it. Nothing is written before the file list is in hand, so a kill during the read leaves no partial state;
  • a stored path arkaik cannot read — a .. segment, which the link form refuses but a restored or hand-edited row can still carry — refuses the whole repository's links for that project and quotes the offending value. It is not read as an empty path: an empty path means the whole repository, which would turn the one row nobody can interpret into the broadest claim in the configuration.

Nothing is fetched unless it is needed. No path-scoped link on the repository means no API call at all — that is every deployment that existed before this feature. Even with them, a PR that mentions no acceptance costs nothing: the vast majority of pull requests in a monorepo never touch this code path.

A PR whose mentions all carry an explicit @platform does cost one call, even though the suffix alone decides the scope. The paths are one of the sources that keep an existing ref moving, and a delivery that read nothing cannot tell a withdrawn scope from an unread one — so it would freeze nothing and leave a stale ref being refreshed to merged and promoted. One request per mentioning delivery is the price of that; it is bounded, and its failure is reported rather than retried.

The plain-@platform arrangement still works and is still supported — link the monorepo with an empty path and "All platforms", and suffix every PR — but the suffix is then load-bearing, and forgetting it makes the largest claim the automation can make rather than the smallest. Path-scoped links exist so that nobody has to be that careful.

Self-hosting (Inkognito)

A deployment that cannot install the arkaik GitHub App gets the same behaviour from the CLI:

arkaik sync --promote

It mirrors ref status from the GitHub API using a local token, then applies the same computeRefPromotions the hosted App uses — one implementation, so the two paths cannot disagree about what a merged PR means. Run it in CI on pull_request and on pushes to your default branch.

What it does not do is read PR text: sync refreshes refs that already exist, so neither a bare mention nor an @platform suffix attaches anything. Repository links play no part either, path-scoped or not — they are how the webhook decides which project and platform a delivery is about, and sync scopes each promotion by the platform already on the ref. Attach the ref once (by hand, or with update_node from an agent) and sync keeps it mirrored from then on.

Known gaps

  • ref_policy has no UI — step 6 requires the raw bundle editor.
  • The refs editor is read-only — a human links a PR by mentioning the acceptance id; attaching a ref by hand in the app is not possible yet.
  • Hosted projects are online-only for writes — every mutation goes to the server as it happens and fails loudly when the network is down; nothing is queued for later. Reads are gentler than that: what a tab has already read is cached in the browser for the session, so moving between a project's surfaces costs no request, and a background revalidation sends the read validator as If-None-Match and gets a bodiless 304 while nothing has changed. The cache does not survive a reload — a return visit reads the project again (see below). Local-first projects still work fully offline, and export is always available.
  • The read cache is not persisted — deliberately, for now. Keeping it in IndexedDB would let a return visit paint from cache and revalidate by ETag, but the straightforward implementation costs a multi-megabyte rewrite per cache event and gates every query behind a restore. The design, and the three correctness hazards it has to answer, are recorded in superpowers/plans/2026-09-10-reactive-data-layer.md § Deferred.
  • propose_idea / file_request do not work against hosted projects — the hosted write path has no journal-only operation yet. They are refused with an explicit message rather than silently dropped.
  • A refusal, a partial file list, and a freeze scope the delivery — not the ref. A ref a webhook declined to promote from is still attached, and if an earlier delivery had already mirrored it to a promotable state it is still promotable, so arkaik sync --promote can move a status from it later. Freezing prevents that state ever being reached by a later delivery; it cannot unwrite one. Refs carry no record of the delivery that attached them, so there is nothing for sync to filter on; closing this properly means either recording that on the ref (a bundle-format change) or removing the ref, and removing is what freezing deliberately replaced — it would need a claim that a platform is absent, which a partial file list can never support.