🇬🇧 English (source) · 🇮🇩 Bahasa Indonesia
ADR-0018 — The build contract against awcms: tenant from the machine token, cursor traversal + hydration, and the translation gate
- Status: Accepted
- Two sentences in §Consequences stopped being true, and the body is
deliberately NOT edited (13 August 2026 — an ADR is a record of a decision at
one point in time):
- "
awcmsADR-0049 §3 already refuses any action other thanreadfor machine credentials" was true until 12 August 2026. SinceawcmsADR-0092 the machine credential class may write: an action ceiling ofcreate/updatein code (not a column), mandatory CIDR binding, refused whenclientIpis unknown, a maximum age of 30 days instead of 365, with the refusal sentinelmachine_credential_write_forbidden. Every credential issued before its migration stays read-only, with no backfill. This ADR's build token therefore still cannot change anything — but because it is issued with no write action at all, that is a property of its row, not of its class. Keeping it in the read class is now an issuing decision that has to be maintained. - "
allowed_permission_keyscontains exactlyblog_content.posts.read" became one key short when ADR-0025 added media resolution: what is correct today is two keys,blog_content.posts.readandmedia_library.media.read. Without the second,scripts/asal-media.mjsis answered with a 403 and the build fails after every page has finished rendering. The values in force are in.env.example.
- "
- Date: 1 August 2026
- Follows on from:
awcmsADR-0049 (machine credentials + session introspection), which closed the two contracts ADR-0047 recorded as holding this repo back - Related: ADR-0014 (the BFF), ADR-0017 (the internal admin surface — superseded by ADR-0020; the machine credentials below are unaffected, they are used by the BUILD token)
This repo pulls content at build time through GET /api/v1/blog/posts. Three
things about that call turned out not to match reality, and all three fail
without failing the build:
src/lib/awcms/tenant.ts sent X-Tenant-Code/X-Tenant-Id. awcms reads
x-awcms-tenant-id, and ADR-0049 §4 refuses to add an alias — the spelling this
repo used never existed over there. For a machine token that header is ignored
(the tenant comes from the token); for any other bearer the result is
400 TENANT_REQUIRED.
The three-level resolution chain (AWCMS_TENANT_CODE → AWCMS_TENANT_ID →
AWCMS_DEFAULT_TENANT_CODE) was therefore answering a question nobody was asking.
GET /api/v1/blog/posts returns a summary (BlogPostSummary on the awcms
side): id, title, slug, status, visibility, locale, publishedAt,
updatedAt, createdAt. There is no contentJson, excerpt,
metaDescription, canonicalUrl, or translationGroupId.
The adapter in this repo declared the FULL post shape for that response and read
contentJson straight from it. The consequence was not an error: contentJson
read as undefined, every article body was empty — and because kategori also
lives inside contentJson, not one article matched any tab. The build
succeeded, the site published, every section empty.
The adapter requested 100 (awcms's upper bound) and threw when the response came
back exactly at that limit. That was the right decision when it was written —
truncating silently is worse — but since awcms added keyset traversal
(?order=created_at with nextCursor), throwing means refusing to build a site
that could in fact be built whole.
AWCMS_API_TOKEN must be a machine credential
(awcmsm_<32 hex tenant>_<43 char secret>). The tenant is derived from the
token, and no tenant header is sent — sending one means setting a value that
looks decisive while being ignored.
AWCMS_TENANT_ID is kept, but changes role: from a source to a verified
statement. If it is filled in and differs from the token's tenant, the build
fails.
This is not a weakening of the guard but its move to where a leak can actually happen. The old chain guarded against "the build guesses the tenant" — a state that is no longer possible. What is possible, and was invisible to everything before, is another tenant's token installed in this site's configuration: a green build, a full site, somebody else's contents. A chain cannot see that; an assertion can.
AWCMS_TENANT_CODE and AWCMS_DEFAULT_TENANT_CODE are refused, not
ignored. A value that reads like configuration but decides nothing is the same
defect class this ADR fixes.
- Walk the whole list with the
?order=created_atkeyset cursor — the only ordering awcms will paginate, becauseupdated_atmoves every time a post is edited so a row can jump across a page boundary. - Discard anything not
published+publicbefore hydration, so a draft never costs a single request. - Fetch each remaining one whole from
GET /api/v1/blog/posts/{id}.
Step 3 is N+1 requests per build. Its cost is accepted and stated, not hidden: correct-but-slow beats fast-but-empty. The real fix remains what the adapter recorded from the start — a build feed on the awcms side returning full rows, keyset-paginated and locale-aware. This ADR does not replace that need; it lets this repo behave correctly before that feed exists.
The MAX_PAGES limit (200 pages ≈ 20,000 posts) is a runaway-loop backstop, not
a content limit: it throws, rather than returning what it has collected.
translationGroupId is accepted by awcms on write and returned by no read
endpoint. Rule 1 of this adapter pairs locales through that field.
Continuing without it does not look like a failure: every non-default locale falls back to the source language, each carrying a "not translated yet" marker, and the site publishes translations that DO exist as untranslated pages. That discards content silently, and this repo treats it as a failure.
Its gate is written as an assertion over data, not as an awcms version check: a single-locale site still builds today, and once awcms returns the field, that gate passes on its own with nothing needing to change here.
A site's build changes from "green but empty" to correct, or fails with a written cause. There is no third state.
Existing deployment configurations will fail once, deliberately. Every site
that sets AWCMS_TENANT_CODE/AWCMS_DEFAULT_TENANT_CODE or a non-machine token
stops building until its variables are fixed. The alternative is to accept both
silently and let an operator believe something that does not hold.
The credential must be issued with the narrowest scope.
POST /api/v1/access/machine-credentials, with allowed_permission_keys
containing exactly blog_content.posts.read. awcms ADR-0049 §3 already refuses
any action other than read for machine credentials, so a leaked token cannot
change anything — but it can still READ tenant data, so its expiry is mandatory
and its revocation takes effect on the next request.
Load on awcms rises. One build is now N+1 requests, not one. For a
500-article site that is some 500 read requests per rebuild, and a rebuild is
triggered on every publish. This is an additional reason the build feed is worth
doing over there, and the reason HYDRATION_CONCURRENCY is capped at 8.
Decision 2 above names the build feed as the "real fix", postponed. It was not
postponed — it landed in awcms the same day
(the build feed PR), so the "N+1
requests per build" part of this ADR only applied for a few hours.
What differs from what is written above:
GET /api/v1/blog/posts?view=full&order=created_atreturns full rows with the same keyset cursor. The adapter walks one traversal; there are no more per-post requests.tests/kontrak-awcms.test.mjsasserts that the per-id request does not come back — because if it did, it would come back silently.translationGroupIdis now returned by bothview=fulland the detail endpoint. The gate in Decision 3 therefore no longer fails a multi-locale site; it stays, and it still guards its condition, precisely because it was written as an assertion over data rather than as a version check.- The page size drops to 50, the limit awcms enforces for
view=fullbecause its rows carrycontentJson.
The root cause is recorded over there too, and is worth repeating here: the
awcms OpenAPI contract stated this endpoint returns a BlogPost, while its
implementation returned a summary. This repo's adapter was written from that
document — its type comment names
openapi/awcms-public-api.openapi.yaml — so the "empty site with a green build"
defect was born from a document promising more than the code delivered. The
summary shape now has a schema of its own (BlogPostSummary).
Reading contentText from the list and stopping there. Not possible: the
list does not contain that either. No form whatsoever of an article page can be
built from a summary.
Pairing translations by matching slug. Refused. Slugs are localised —
that is precisely why translationGroupId exists. A pairing rule living only in
this repo is a rule that does not exist, and a mispairing publishes the wrong
article under the right title.
Building the feed now in awcms. Postponed, not refused: it is a change in
another repo, with its own ADR and security review. What this ADR decides is how
this repo behaves correctly while that feed does not exist.
Accepting a human session token as the build credential. Refused, and the
reason is in awcms ADR-0049 §Context: sessions expire, a password reset revokes
every session of that identity, and an MFA step-up rotates them. The build would
die at a moment nobody could predict, far from its cause.