Skip to content

Publishing the Creator SDK Surface — ADR-115

Publishing the Creator SDK Surface — ADR-115

One ruling. It extends the scrubbing pipeline from .fbs wire schemas to C#, so the creator SDK’s contract can be published without publishing the reasoning around it. Numbering: 114 is the highest allocated; this is ADR-115. The evidence for its sharpest clause — that XML doc comments are stripped rather than published — was read out of the SDK and the room grain rather than assumed, and is quoted below.


ADR-115 — The C# Scrubber Is Extractive, Scoped to the SDK, and Publishes Signatures Without Their Prose

Status: Accepted · Date: 2026-08-08 · Depends-on 111 (the core/ boundary defines what must never be published; this ADR’s scope guard is meaningful only because that boundary exists) · interacts with 112, 113, 114, 098

Context

The .fbs pipeline publishes three wire schemas through a fail-closed scrubber (ADR-112). Nothing publishes C#. A creator writing a cartridge compiles against Descent.Vtt.Sdk and has no rendered reference for it — the contract exists only as source in a repository ADR-111 closed.

Publishing C# is not the same problem as publishing a schema, and the difference decides the mechanism. A .fbs file is a small, wholly-reviewed declaration. A C# file is a compilation unit that mixes the contract with its implementation, its serialization plumbing, and — decisively — prose written by insiders for insiders.

The evidence, read rather than assumed (P2)

Three properties of the actual source rule out the obvious designs:

  1. The doc comments carry exactly what §3.1 and ADR-111 protect. IRulesetEngine’s XML docs cite Q-060/Q-061 by name, restate ADR-055’s refusal rule, and explain why an undeclared key is refused — “a retired attribute could be resurrected by spelling it and nothing would notice”. IActor’s docs state that a cartridge able to write host state “would bypass premise validation and §9.2’s authority rules, and the bypass would look like ordinary object use in review”. Those are the corpus’s attacker-relevant reasoning, sitting in a file a naive pipeline would publish whole.
  2. A file is not a unit of publishable content. IRulesetEngine.cs also declares RulesetOutcome, a record struct with a private readonly field, a private constructor and expression-bodied members. Publishing the file publishes the implementation; publishing “the public parts of the file” still publishes a private field’s existence and the shape of the type around it.
  3. public is a language requirement, not a publication decision. IRoomGrain is entirely public because Orleans requires it, and its own doc comments mark four members “Internal to the saga” and one “Internal to the outbox”. The author already knew those were not for external consumption and only prose recorded it. Its docs additionally describe a live exploit — a client that could name its own role “could name GameMaster and be handed every secret door in the map” — and record a protection that does not exist yet (“Entity destruction has no implementation yet”).

Decision

  1. The pipeline is EXTRACTIVE, not redactive. It emits only what it positively recognises as a publishable signature; everything it does not recognise is simply not emitted. This inverts the .fbs scrubber’s subtractive model deliberately, and the reason is the same one that makes its file allowlist safer than a denylist: a redactive pass over C# would only remove what someone thought to list, while an extractive pass fails towards silence. Its limitation is its safety property, and a more capable parser would be a worse disclosure boundary.
  2. Scope: modules/Descent.Vtt.Sdk/ only. Every publishable C# source must sit under that directory. Nothing from core/ may ever be published (ADR-111), and nothing from apps/vtt-backend-silo/ — the grain interfaces are the internal actor boundary rather than a creator contract, and IRoomGrain is the worked counter-example above. Widening the scope is a deliberate edit to both the manifest and the guard test, with its own argument — the arrangement the .fbs pipeline already uses for its schema directory.
  3. Only public interface declarations and their member signatures are published. Classes, records, structs, enums, fields and implementations are outside the grammar and are never emitted. A member carrying a body — { … } or => — is an implementation and is refused rather than trimmed.
  4. Every comment is stripped, including /// XML documentation. This is the clause with the most cost and it is taken on the evidence above: a doc comment is prose written without publication in mind, and this corpus’s rule is that unreviewed prose is not published. What a creator gets is the contract; what a creator does not get is the platform’s reasoning about its own weaknesses. Deliberately-authored creator documentation is a separate artefact with its own review, and its absence is a stated gap rather than an oversight.
  5. Member-level suppression reuses the existing internal-marker grammar — the token ADR-112’s scrubber defines, which this record describes rather than spells for the reason R20 clause 3 records and the owner ruled absolute: this page ships through the pipeline whose belt scans every published byte, and the belt admits no prose exemption. No inverted @publish convention is introduced: one marking grammar across .fbs and .cs, because two conventions that mean opposite things is the confusion this corpus keeps recording. A marked member is omitted and its omission is counted on the page, exactly as withheld schema lines already are. Only a whole-line marker comment suppresses; every other form carrying the token refuses the scrub — a doc comment, a block comment, or the token buried in ordinary prose. This is the .fbs grammar’s “unrecognised marking is treated as marking, never as prose” rule, and it is here because the mutation sweep found the opposite: a marker written as a doc comment was being silently ignored and its member published, so an author intending suppression would have got disclosure.
  6. Attributes are allowlisted, not passed through. [Alias] survives because it is part of the routing contract a creator must match. Every other attribute is dropped: [Id(n)] publishes serialization ordinals, and an unrecognised attribute is unreviewed content by definition.
  7. The audience condition is inherited, not re-decided. These pages join the published set, so ADR-113 clause 6 and ADR-114 clause 3(iv) apply to them unchanged — the C# pages are part of what a public-audience disclosure pass must cover, and this ADR authorises rendering at the current collaborator-only audience only.

Alternatives Considered and Why Rejected

  • Publish the XML doc comments, with or without a per-comment marker. Rejected on the evidence in §Evidence: the SDK’s own docs cite quantities, restate refusal rules and describe bypasses. A marker-gated variant fails on the same argument clause 5 makes about inverted conventions, and it would put the disclosure decision in the hands of whoever last edited a comment.
  • Use a real C# parser (Roslyn, or a JS C# grammar). Rejected twice over: it puts a .NET toolchain or a heavyweight grammar inside a static docs build (P8 exit cost, for a pipeline ADR-112 deliberately kept dependency-free), and a parser that understood C# fully would faithfully reproduce more than this boundary wants. Fidelity is the wrong objective for a disclosure filter.
  • Publish IRoomGrain and the silo’s grain interfaces as the “modder API”. Rejected: it is not a creator contract, it mixes saga-internal callbacks with the command surface, and its prose is the richest source of attacker-relevant material found in this review. Recorded because “the grain interface is the API” is the first thing a reader will propose.
  • Opt-in @publish markers per member instead of scope-plus-extraction. Rejected for now on clause 5’s grounds, and recorded as the re-proposable option if the scope ever widens beyond the SDK — at which point per-member intent stops being derivable from the directory a file sits in.
  • Publish nothing in C# and hand-write creator documentation. Rejected as the status quo it describes: hand-written API prose drifts from the compiled contract, which is the defect class this corpus names most often. The generated page cannot drift, because it is regenerated from the source on every build.

Consequences (including negative)

  • The published pages are signatures without explanation, and for a creator reading them cold that is a genuinely worse experience than a documented API. The mitigation is authored creator documentation, which does not exist; naming it here is the honest position rather than pretending the generated page is sufficient.
  • The extractor will silently omit a member whose syntax it does not recognise. That direction is deliberate — omission cannot leak — but it means the published surface can be incomplete without anything failing. The vacuity guards catch the total-failure case (no interface, or an interface with no members); they cannot catch a single unparsed member. Stated rather than implied.
  • A second manifest exists alongside the .fbs one. They stay separate because their grammars and scope guards differ; the route check consumes both, so neither can publish a page the site does not expect.
  • Generated pages are never committed, like every generated artefact here — the source stays the single source (P6).

Rights-holders (ADR-079)

None. The decision publishes type signatures the repository already holds. No new class of data about any person is created or retained.

Enforcement

  • tools/csharp-schema-scrubber.test.mjs — the grammar, every refusal, and the belt, each case written as a mutation that must turn the suite red. Swept 2026-08-08: eleven guards disabled one at a time, ten killed the suite outright. The two survivors were not noise — one exposed the missing unrecognised-marking refusal in clause 5, now built and killable; the other (the non-public modifier predicate) is covered independently by the belt, which is defence in depth rather than a gap, and is recorded as such rather than contorted into a test.
  • The scope guard is a test: any manifest entry outside modules/Descent.Vtt.Sdk/ fails, with core/ asserted explicitly so ADR-111’s boundary has a check rather than a convention.
  • The output belt: if private, internal, protected, a member body, a comment token or the internal marker survives into any emitted page, the scrub fails rather than shipping it — the same belt-over-the-grammar shape the .fbs scrubber carries.
  • docs-schema-scrubber.mjs --check dist already scans the built site for the marker token and now covers these pages too.
  • docs-publication-check.mjs derives the expected C# page routes from this manifest, so a page appearing or disappearing is a two-sided build failure.