Project Context Separation Design

Intent

Coding agents on jakemon, Roller, Stockroom and Wikantik all start their sessions from the same wiki. Each one needs its project's context plus the cross-project contracts that bind it — and should not be fed another project's implementation plans. Today the briefing can't tell the two apart, so a Roller telemetry session gets a Stockroom photo-identification plan as "standing context".

This design makes the briefing project-aware without declaring projects anywhere. The separation and the alignment both fall out of structure the corpus already has: cluster membership (a project is its cluster) and wikilinks (a project cites the contracts it is bound by). The one piece of explicit structure is a content fix — Stockroom gets the hub and cluster every other project already has.

What simulated retrieval showed (prod, 2026-10-23)

Eight get_briefing calls were run against prod before this was written, one per project plus the cross-cutting cases. Every row below is reproducible with the call shown.

#CallTask-relevant sectionsStanding context (after the hub)Pointers
1clusters=[simple-agility], prompt: Roller telemetrycorrect (Adoption § Roller, Handoff spec, SA-7)StockroomPhase7PhotoIdentificationPlan20, 11 of them Stockroom plans
2clusters=[roller], same prompt, PREFER10 of 12 from one page (RollerTelemetryHandoffSpec); SimpleAgilityTelemetryContract absentRollerMcpcontract pages absent — not members
3clusters=[roller], same prompt, STRICT11 of 11 from one page; the one cross-project section (Adoption § Roller) droppedRollerMcp, RollerW3tPlugins5, no contracts
4clusters=[jakemon], prompt: new push feedgood (JakemonHub, ObservabilityPlane — a manual multi-member)IncidentWebmasterProviderStale45; 42 incident runbooks ahead of the two contracts
5clusters=[stockroom]fine via prompt (CommerceDataFlow)none — unknown cluster: stockroomnone
6no cluster, prompt: job runner + lease guard11 of 12 from one page (BackgroundJobProcessing)——
7clusters=[wikantik-development], prompt: trace.idgood mix, but RollerTelemetryHandoffSpec ×2 leaked inLoginHelp130, incl. SandBox, UnusedPages, RecentChanges
8any call with scope="strict" (wrong name; the argument is scope_mode)silently ran as PREFER——

Read across the rows, the defects are:

  1. Standing context is chosen by recency (rows 1, 2, 3, 4, 7). ItemAssembler.clusterMembers orders hub-first, then updated descending. "Most recently edited" is a fine signal for a changelog and a bad one for "what every session in this project should know": it produced a Stockroom plan, an MCP runbook, an incident runbook and a login-help page.
  2. A project cannot reach its contracts (rows 2, 3). Nothing expresses "Roller is bound by these shared pages"; STRICT makes it worse by dropping the one cross-project section the prompt found. The clusters: [simple-agility, jakemon] multi-membership on two contract pages (row 4) is a hand-made version of the missing link for one project.
  3. Sibling projects leak in both directions (rows 1, 7): Stockroom into a Roller/stack briefing, Roller into a Wikantik one.
  4. One page can take the whole task-relevant budget (rows 2, 3, 6: 10/12, 11/11, 11/12). The bundle caps sections per page at 20; the briefing's 12-section budget has no diversity rule at all.
  5. Pointer lists are unranked and uncapped (rows 4, 7): 45 and 130 pointers in updated order, so contracts sit under runbooks and system pages.
  6. stockroom is not a cluster (row 5) — Stockroom pages are parked in simple-agility.
  7. Misnamed arguments are silently ignored (row 8). The MCP tool and /api/briefing accept unknown parameters without a warning, so a caller who writes scope gets PREFER and no signal.

Behind defects 1–3 are two structural causes:

  1. The simple-agility cluster is two things at once. 11 of its 22 pages are Stockroom-local designs and plans, parked there because "Stockroom has its design page until it has a hub" (SimpleAgilityStackHub § Components). Every other project has its own cluster (jakemon, roller, wikantik-development, wikantik-platform).
  2. Standing context is chosen by recency. ItemAssembler.clusterMembers orders members hub-first, then by updated descending. "Most recently edited" is a fine signal for a changelog and a bad one for "what every session in this project should know". Fixing cause 1 alone would leave this: the next most recently edited contract page is as likely to be the Attribution RFC as the Telemetry Contract.

There is a third, latent gap: nothing today expresses "Roller is bound by these shared pages". The clusters: multi-membership on SimpleAgilityTelemetryContract ([simple-agility, jakemon]) is a hand-made version of it for one project. It works, but it is curation that has to be remembered per contract per project — exactly the explicit structure this design prefers to derive.

Goals

Non-goals

Anti-goals (what would make this a failure even if it worked)

Design

Vocabulary

All four are pure Page Graph derivations; the structural index already holds membership and DefaultReferenceManager already holds the link table.

Reading citations as adoption

The tiers are easiest to reason about through one sentence, which is also ADR-0011's statement:

A citation from a project page to a shared page is that project's adoption of it.

A cross-cutting decision lives on one page (SimpleAgilityDecisionLog SA-6, or the contract page it points at) and does not need to know whom it binds. Each bound project says so the only way content can say anything: a page in that project cites it. Tier 2 is therefore the set of shared decisions this project has adopted; reach is how cross-cutting a decision actually is; and the decision log's Binds column is the declared form of the same relationship.

Consequences:

The limit is the same fact read the other way: a decision reaches a project only if the project cites it. The briefing reports what the corpus says, not what it should say. The coordination protocol's existing rule — adopt a contract by linking to it ("contract-first modifications" on SimpleAgilityAgentCoordination) — is what keeps the two aligned, and this design is what gives that rule teeth.

Three tiers instead of one list

A cluster-scoped briefing currently has one scope: "slugs in the cluster". It becomes three ordered tiers, computed once per request from the structural snapshot:

TierContentsOrdering within tier
0Pins (unchanged)request order
1Members of each requested clusterhub first, then in-degree desc, then updated desc
2Cited pages of each requested clusterciters desc, then reach desc, then updated desc; capped

Tier 1's ordering change is what fixes the "most recently edited plan becomes standing context" defect on its own, for every cluster. Tier 2 is what makes a roller briefing carry the Telemetry Contract, Decision Log and Stack Hub without any page declaring that it does: Roller's own pages cite them.

Which links count. Every edge DefaultReferenceManager holds — body wikilinks and frontmatter related: entries — counts toward in-degree, citers and reach. related: is deliberate curation and is exactly how a hub names its members and its contracts; a hub's related: list gives each member in-degree 1 uniformly, which does not distort ordering among them (sibling-to-sibling links do the differentiating). No page type is excluded from tier 2: another cluster's hub is often precisely the page a project should cite (the Stack Hub for every Simple Agility project). The per-request cap and citers-first ordering bound what a widely-linked generic page (Main, a glossary) can do: it enters tier 2 only if this cluster's members cite it, and ranks by how many of them do.

Where the numbers come from. Per request, scoped to the request: members of the requested clusters from StructuralIndexService (≤ MAX_CLUSTER_MEMBERS), their outbound links from ReferenceManager, and findReferrers on each candidate cited page to compute citers and reach. That is a few hundred map lookups, not a scan of the link table; it needs no new index, no cache and no invalidation. Precomputing at structural-snapshot build time is the optimisation if measurement ever shows it is needed — not the v1 design. (PageGraphSnapshot is unsuitable as the source: it is ACL-redacted per viewer and carries only the primary cluster.)

The tiers feed both halves of the briefing:

Out-of-scope sections become pointers, not body text

In PREFER mode, out-of-scope bundle sections are today appended as ordinary sections and spend budget on body text. That is the second way a sibling project's plan reaches an agent. They become a compact "Elsewhere in the wiki" list: page title › heading, canonical_id, cluster, one line each, grouped by cluster, capped (wikantik.briefing.elsewhere.max, default 8). This is the cross-project alignment channel: a Roller agent asking about a job runner sees "Stockroom › Lease guard for the identify job" as a pointer it can read_pages, at the cost of a few dozen tokens rather than a section body.

Elsewhere entries are BriefingItems with origin elsewhere, so BriefingAclGate filters them exactly as it filters every other item (a restricted page's title and cluster must not reach an anonymous caller). They are budgeted the same way existing pointers are, and they are excluded from the "Available on request" rendering and from the pointerCount statistic, which keeps its meaning; they get their own elsewhereCount.

The renderer gains two headings. Rendered order:

  1. Task-relevant sections (tier 1, then tier 2)
  2. Standing context — this project (tier 1 full bodies)
  3. Contracts this project cites (tier 2 full bodies or pointers)
  4. Elsewhere in the wiki (out-of-scope pointers)
  5. Available on request (unchanged)

Headings 3 and 4 are omitted when empty, so briefings without clusters render exactly as today.

Diversity: a per-page cap on task-relevant sections

The briefing takes the bundle's top sections in order until its section budget is spent. Three of the eight simulated runs spent ≥ 10 of 12 on one page. The briefing gains its own per-page cap (wikantik.briefing.sections_per_page, default 4), applied after the tier partition so that tier-1 sections of a page are preferred over its tier-2 ones.

A cap only helps if there is something to fill the vacated slots with. BundleAssemblyService.assemble returns the bundle's own top-N (12 by default), so the briefing today never sees a 13th candidate. The service gains an additive method — assemble(query, options) with a maxSections override, defaulting to today's behaviour, in the same style as the existing assemble(query, RetrievalMode) — and the briefing asks for a candidate pool of 3 × its section budget. The pool is then tier-partitioned, per-page-capped and truncated to the budget. /api/bundle and assemble_bundle do not call the new method and are unchanged. Displaced sections of a page remain reachable through the existing assemble_bundle deepening hint.

The cap applies to every briefing, including one with no clusters (row 6). "Renders exactly as today" in this document therefore means the same headings, not the same section selection.

This is the one change in the design that is not about separation; it is included because the simulation showed it degrades every project's briefing and because the same eval measures it.

Pointers: "Available on request" is ranked and capped

Pointer items follow the same tier ordering as full items (tier 1 by in-degree, then tier 2 by citers), capped at wikantik.briefing.pointers.max (default 30). A member with neither a summary nor any inbound link from its cluster (the SandBox/UnusedPages class) sorts last and is the first to fall off the cap. The MAX_CLUSTER_MEMBERS = 200 hard cap stays as the outer bound.

Unknown arguments warn

GetBriefingTool and /api/briefing add a unknown argument: <name> warning for any parameter they do not recognise. The call still runs with defaults — the existing degradation contract — but the caller can see it. Same treatment for assemble_bundle is cheap and belongs in the same change.

Degradation

Why not a pattern tag

A pattern/ecs-logging-style tag was the obvious simple mechanism. It was rejected as the backbone, not because it is simple-minded, but because of where the work lands:

The evaluation (Phase 1) is the arbiter. If it shows a class of case the citation tier structurally cannot reach — a pattern two projects share with no page linking the two — a pattern: tag convention is the recorded fallback, added at that point with the evidence, not before.

Why not the Knowledge Graph

Shared entities across clusters (both Roller and Stockroom pages mention "lease guard") are a real signal for peer discovery. It is deferred: it would add an inference dependency to the session-start path, the KG measured as zero-lift when used as a retrieval signal, and the out-of-scope pointer list already surfaces the same peers whenever the prompt names the concern. Revisit only with the Phase 1 harness showing misses.

Phases

Each phase is independently shippable and leaves the system better than it found it.

Phase 0 — Corpus hygiene (content only, no code)

Acceptance: in the simple-agility briefing from row 1, no Stockroom page appears as standing context or under "Available on request" — they are no longer members. (After Phase 2, StockroomHub and the design page the Stack Hub's components table cites may legitimately appear as tier-2 cited pages of simple-agility: the contracts cluster citing each project's entry page is exactly the relationship the mechanism is meant to surface. Stockroom phase plans must not — nothing in simple-agility cites them — and the eval case asserts that distinction, not a blanket ban.)

Phase 1 — Measure first

Acceptance: the harness runs against prod and records the post-Phase-0 baseline in eval/briefing-scope/BASELINE.md.

Phase 2 — Tiered scope in the briefing (the code change)

Acceptance: Phase 1 tests green; harness separation score rises on every case; no case loses a must_include.

Phase 3 — Make it the protocol

Phase 4 — Conditional and follow-on

Evidence-gated: only if the Phase 1 harness, run after Phase 2, shows cross-project misses that links cannot reach: either the pattern: tag convention or KG co-mention as an additional tier-2 source, chosen by which one the failing cases actually need. Not planned; recorded so the trigger is explicit.

Natural follow-on (not gated, not in this program): an unadopted decision drift check for /admin/drift — for each decision-log entry, the clusters its Binds column names minus the clusters with a page citing its normative page. It needs the Binds column in a machine-readable form (today it is prose in a table), which is a content convention to settle on SimpleAgilityDecisionLog first.

Constraints and gotchas for the builder

Left to the builder

Calls made in this document (check these while reading)

Amendments (2026-10-23)

Recorded after Phase 2 was built. Earlier text above is left as approved; where it differs, this section governs.

  1. No tier on sections in JSON. Phase 2 said "JSON output gains tier on sections and items" and an elsewhere[] array. Sections carry no tier field: they are ordered by tier (members, then cited pages). Items carry no tier either; they carry origin (pin, cluster, cited, elsewhere) and cluster, and the out-of-scope pointers are ordinary entries of items[] with origin elsewhere.
  2. The candidate pool is an explicit key. "Diversity" sized the pool as 3 × its section budget. It is the key wikantik.briefing.candidate_pool (default 36), a count of sections requested through BundleAssemblyService.assemble(query, BundleOptions), independent of the token budget. Nine wikantik.briefing.* keys were declared in ini/wikantik.properties: the five Phase 2 listed, candidate_pool, and enabled, default_budget and max_budget, which had been code-only defaults.
  3. assemble_bundle gets no unknown-argument warning. "Unknown arguments warn" said the same treatment for assemble_bundle was cheap. ContextBundle has no warnings channel, so adding one would change the wire shape of both bundle surfaces, which this design holds fixed. Only /api/briefing and get_briefing warn.
  4. Per-page cap test. The plan's perPageCapPrefersTier1SectionsOfAPage was replaced by perPageCapKeepsFirstNSectionsOfAPageInRankOrder: a page belongs to exactly one tier, so there is no within-page tier preference to test; the cap keeps a page's first N sections in rank order, applied after the tier partition.

Cluster Hub: Wikantik Platform Development Hub: Architecture, MCP Protocols, and Agent Briefings (wikantik-development)