Lookup page for the serve-time gate. For the authoring sequence, start at Restrict who can read a page.
Evidence on this page is the merged implementation in arc#4065 (3fa375a41) plus a local run of this site on ARC 2.0.0-beta.35 at http://arcblock.localhost:4922/ on 2026-08-15.
Where to write the policy
| Location | Field | Applies to |
|---|---|---|
blocklet.yaml | accessDefault: public | Site-wide hatch: unmarked routes and records inherit public only when this exact sentinel is written. Absent means omit-deny |
blocklet.yaml | accessPaths[] | Locale-stripped page path. Longest match wins |
blocklet.yaml collections.<type> | access | Content details of that type. Not the collection list page |
| Route / page | access | List and static pages. Not a substitute for a missing record on a detail |
| Content object front matter | access | That record only. Tightens the scope; cannot relax it |
| Content object front matter | preview | Denied-reader shell. Does not make the body public |
collections.<type>.readRole is not this table.
Resolution
Policy is a ceiling, in this order:
- Longest matching
accessPaths[].match(locale prefix already stripped). collections.<type>.accesson a content detail. Ignored on the list page so a type-levelmemberdefault does not lock/articles/.- Route
accesson list and static pages. Details do not inherit a public list route as if the record were public. accessDefault: public.
Then the record:
Record access | Effect |
|---|---|
| omitted | Inherit the scope. If the scope is public, the page is public. If there is no scope, undeclared → 404 |
public | No extra role. Does not unlock a locked scope |
none | Hard deny. Admin and owner cannot inherit it |
| any other string | AND-tighten. The caller must satisfy every named role from scope and record |
admin and owner in the caller’s role list admit any declared gated resource. They do not admit an undeclared page or none.
Admit is contains-match on caller.roles, not a numeric role level. A caller with both member and admin satisfies an admin page. A caller with only member does not.
Path match:
match | Hits |
|---|---|
/docs/internal/** | /docs/internal/runbook, not the list /docs/internal |
/articles/members-only | that exact detail, and /articles/members-only/... |
| longer prefix | wins over a shorter one |
accessPaths:
- match: /docs/internal/**
access: admin
- match: /docs/internal/public-note
access: publicDecision kinds
decideWebAccess returns one of:
| kind | When |
|---|---|
full | Caller is admitted |
preview-shell | Denied, and preview: true |
promo-shell | Denied, preview is not true, and the record is listable |
not-found | Undeclared, none, or denied and not listable |
On a content detail, preview-shell and promo-shell live-render through the site layout. Only not-found short-circuits as a bare 404. SiteServer then:
- If
preview !== false, cut a first-screen teaser (PAYWALL_TEASER_CHARS = 480,PAYWALL_TEASER_MAX_BLOCKS = 2, last top-level block withheld, leading H1/H2 that repeats the hero title dropped). - If that teaser HTML is non-empty, the visible gate copy uses the preview phrasing even when
kindispromo-shell. - If
preview: false, no teaser. Listable → gate only. Unlistable → 404.
CJK characters count as 2 visual units.
HTTP, cache, and SEO
| Outcome | Status | Cache | Robots |
|---|---|---|---|
| Full public page | 200 | no-cache on this capture | indexable |
| Full gated page (admitted caller) | 200 | no-cache on this capture | no forced noindex |
| Teaser / gate | 200 | private, no-store | X-Robots-Tag: noindex and <meta name="robots" content="noindex"> |
| 404 | 404 | private, no-store | n/a |
Shared public edge cache is legal only when kind === "full" and effective access is public. Prerender may still write the full body into .web-cache/<path>/index.html. The serve gate must run before that file is replayed. Do not publish .web-cache as a static host.
Gated HTML may advertise only a declared SEO description or excerpt. A docs summary derived from the protected body does not go into meta, Open Graph, or JSON-LD. post-hero coverImage is cleared on a shell. Accept: text/markdown is refused on a gated detail.
Live capture, anonymous admin fixture:
HTTP/1.1 200 OK
cache-control: private, no-store
x-robots-tag: noindexLive capture, same URL with x-caller-did + x-caller-role: admin (local seam only):
HTTP/1.1 200 OK
cache-control: no-cacheBODY_MARKER_ADMIN_ONLY present; no access-gate.
Discovery versus the HTML list
Anonymous sitemap, llms.txt, MCP get, and index query force listedOnPublicList: false.
| Record | HTML public list | Anonymous discovery |
|---|---|---|
public or inherited public (collection / path / hatch) | Full card | Full fields (arc#4086) |
gated, preview: true | Promo / preview card | Shell fields only; body stripped |
gated, no preview: true | Promo card if listable | Absent: no URL, no body |
undeclared or none | Absent | Absent |
Media and feeds
Item-local media and collection RSS use the same admit function. Preview and promo shells never grant bytes. A public list card is not a media admit.
Local caller seam
When the process has AFS_TEST_ALLOW_CALLER_HEADER=1:
| Header | Role |
|---|---|
x-caller-did | Required. Role headers without it do not admit |
x-caller-role or x-caller-roles | Comma-separated role names |
This is a test hook. It is not a public authentication API.