Date: 2026-05-14 Status: Accepted
Decision Makers: Maintainer
Tags: config, core, schema, wire-contract, security
Bedrock pushes each resource's real name, description, and icon to Open
Cloud on every deploy. Those fields render on the experience's roblox.com page.
A Roblox browser extension that bypasses the unreleased-place gate can expose
that page to anyone who knows the URL, leaking in-development monetization
metadata before a release.
No mechanism exists to deploy "structurally real but content-hidden" resources. Manual workarounds -- editing placeholders on the dashboard, maintaining a parallel dev-names config -- break IaC reproducibility.
The threat model is a Roblox-rendered page made accessible through an access-control bypass. This is not a defense against an actor with the developer's Open Cloud credentials or access to the source config repository.
Add a redacted field to the project config schema (ADR-020) that opts a
resource or environment into bedrock-supplied placeholder content. When active,
bedrock substitutes placeholders for display metadata before pushing to Open
Cloud. The real values stay in config and ship only when redaction is lifted.
Resource-entry shape varies by kind, reflecting each kind's underlying field names.
For gamePass, developerProduct, and badge:
interface PassEntry {
redacted?:
| boolean
| { description?: string; icon?: Record<"en-us", string>; name?: string };
}For universe (note displayName, not name):
interface UniverseEntry {
redacted?:
| boolean
| {
description?: string;
displayName?: string;
icon?: Record<"en-us", string>;
};
}Environment-overlay level:
interface EnvironmentEntry {
redacted?: boolean;
}No project-root-level field. Enabling per-env on each non-production environment is the expected pattern.
The object form implies redaction is enabled (specifying an override turns it
on). An empty object (redacted: {}) is a validation error; authors must write
redacted: true for defaults. Override keys are restricted to the redactable
field set for that kind; unknown keys (e.g. price) are validation errors. Both
rejections attribute the offending field path.
- In scope:
universe,gamePass,developerProduct,badge - Out of scope:
place(the.rbxlfile is the content, not metadata);module(no display surface)
| Kind | Redacted by default |
|---|---|
gamePass |
name, description, icon |
developerProduct |
name, description, icon |
badge |
name, description, icon |
universe |
description, icon only |
The universe asymmetry is deliberate: displayName surfaces in Roblox Studio's
place picker and the Creator Hub experience list -- developer-facing tools the
experience owner needs to identify their own work. Redacting it by default would
make the experience harder to locate during development than before redaction
was enabled. Authors who need full opacity write
universe.redacted: { displayName: 'Hidden Project' } explicitly.
Most-specific layer wins:
environments['x'].passes['k'].redacted
> passes['k'].redacted
> environments['x'].redacted
Per-resource inside an env overlay beats per-resource at root, which beats the
env-level boolean. redacted: false at a more-specific layer carves out an
exception in an otherwise-redacted env.
| Kind | Name field | Default | description |
icon |
|---|---|---|---|---|
gamePass |
name |
"Redacted Pass" |
"" |
embedded placeholder PNG |
developerProduct |
name |
"Redacted Product" |
"" |
embedded placeholder PNG |
badge |
name |
"Redacted Badge" |
"" |
embedded placeholder PNG |
universe |
displayName |
(no default; explicit override required) | "" |
embedded placeholder PNG |
The placeholder PNG ships as an inlined Uint8Array in @bedrock-rbx/core. A
sentinel path constant is assigned when applyRedaction substitutes an icon;
readBytes recognizes the sentinel and returns the embedded bytes. Downstream
normalize and driver upload paths receive bytes through the existing interface
unchanged.
load config
-> validate
-> select environment (merge env overlay)
-> applyRedaction <-- new pure transform
-> render displayName prefix
-> flatten per-kind
-> normalize per-kind (hash icons; sentinel resolves through readBytes)
-> diff
-> apply via drivers
applyRedaction is a pure function in core/redact-resources.ts. It receives a
ResolvedConfig and returns a ResolvedConfig; no I/O. Placement between
env-overlay merge and display-name-prefix application means an explicitly
redacted universe displayName composes with the prefix: [DEV] Hidden, not
the real name with a prefix.
The state file records the values that were actually pushed -- placeholders for redacted resources. Diff compares desired against current with no special-cased path. A redacted resource whose config-level real name is edited produces a noop (the placeholder did not change); the plan output surfaces this case explicitly so the noop is not silent.
No state-file schema bump. No diff-algebra change. ADR-019 is unaffected.
arktype narrows enforce:
redacted: {}-- rejected; error attributed to the field pathredacted: { price: 0 }-- rejected; error namespriceas an unknown override keyenvironments['x'].redacted: { name: 'X' }-- rejected; env overlay accepts boolean onlyredacted: true,redacted: false,redacted: { name: 'X' }-- all accepted
- Developers can deploy to a dev environment without exposing real monetization metadata on the roblox.com experience page.
- Additive config extension: authors who do not declare
redactedsee exactly current behavior. No migration, no state-file bump. - Redaction state is reproducible: the same config produces the same placeholders on every deploy, preserving IaC guarantees.
- Drift detection works without modification: the state records placeholders, so a manual dashboard edit to a redacted resource shows as drift on the next run.
applyRedactionis pure and independently testable. No adapters, no I/O.- The sentinel-path approach keeps the normalize and driver upload paths unchanged; no per-kind code needs to know about redaction.
- Universe
displayNameasymmetry is a wart:redacted: trueon universe does not redact the display name, which may surprise authors who expect uniform treatment. The override escape hatch exists but requires extra explicit config. - A per-resource override at root (e.g.
passes['x'].redacted: { name: 'Closed Beta' }) is overridden by any redacted setting on the same resource inside an env overlay, including a bareredacted: true. Authors who want a root-level custom placeholder to apply in every env must leave that resource'sredactedfield absent from env overlays, or repeat the override on each env overlay's entry. - Plan output must explicitly surface the "noop due to redaction" case to prevent silent surprises when the real name changes in config but the placeholder does not. Failing to implement that annotation faithfully would undermine user story 8 (the plan-output story).
- Localized
nameanddescriptionredaction is out of scope: those fields are single strings in the current schema. When per-locale name and description land, redaction will need to fold them into the default field set.
An env-level field that accepts the full override object, not just boolean, would allow a single custom placeholder applied across all kinds in that env.
Rejected. The use cases that motivated this feature are satisfied by
per-resource overrides at the resource entry level. A cross-kind env-level
override object introduces questions about kind-specific field eligibility (e.g.
universe does not redact name by default) that per-resource overrides do not.
Complexity deferred until a concrete use case demands it.
A top-level redacted: true that redacts every redactable resource across all
environments.
Rejected. Bedrock configs enumerate environments explicitly. Redacting everything in all environments, including production, is not a use case the feature serves; the threat is dev/staging leakage, not production leakage. Per-env redaction on non-production environments is sufficient and less dangerous.
A sidecar file (e.g. bedrock.redacted.config.ts) holding placeholder values,
merged at load time.
Rejected. Adds a second config-discovery concern and a new load-time
artifact for a feature that composes naturally as a field on existing resource
entries. The redacted field is authored alongside the resource it affects,
keeping the config self-contained.
Rather than storing placeholder content, store encrypted real values or omit display metadata from state entirely.
Rejected. Encryption requires key management and makes diff dependent on a
secret at runtime. Omitting display metadata from state breaks drift detection.
The threat model is Roblox-side leakage through a page-access bypass, not state
file compromise; authors who need state-file confidentiality should choose a
locked-down state backend.
New modules in core/:
redact-resources.ts--applyRedaction(config: ResolvedConfig): ResolvedConfig. Owns precedence resolution, per-kind default field sets, and substitution. Single exported function; no I/O.redacted-icon.ts-- embedded placeholder PNG asUint8Array, sentinel path constant,isRedactedIconPathpredicate.
Modified:
core/schema.ts-- extends each redactable entry type withredacted?: boolean | RedactedOverrideand each env-overlay type withredacted?: boolean. arktype narrows reject empty-object form, unknown override keys, and object form on env overlays.core/select-environment.ts-- callsapplyRedactionbetween env-overlay merge and display-name-prefix application.core/kinds/read-bytes.ts-- short-circuits the sentinel path to embedded bytes.shell/preview-diff.ts(or the diff renderer) -- annotates plan output with a "redacted in <env>" line per redacted resource so real-value edits that produce a noop are surfaced.
- ADR-017 -- Product Framing: the
redactedfield is part of the public config API surface. - ADR-018 -- FCIS + Ports:
applyRedactionis a pure core transform; no I/O, no ports. - ADR-019 -- State Data Model and Diff Algebra: explicitly not amended; this design is additive within the existing data model and diff contract.
- ADR-020 -- Project Config Definition: this ADR extends the schema ADR-020
defines. The per-resource entry schema and the env-overlay schema both gain
the
redactedfield.
The original Decision named universe as a redactable kind with a default field
set of description and icon. Implementation found that universe owns neither
field as wire-pushable today:
iconfor the universe is blocked upstream. Open Cloud has no endpoint to set a universe's source-language game icon. The block is tracked in the Mantle-feature-blocked register; see issue #362.descriptionis not owned byuniverse. The universe's description is derived server-side from the root place'sdescriptionfield, which already lives onPlaceEntry. The redactable description field is therefore on theplacekind, not onuniverse.
displayName remains the only universe field redactable today, routed through
PlacesClient.update on the root place (same path the universe driver uses).
The asymmetric-default design (preserve displayName so Roblox Studio's place
picker and the Creator Hub experience list still identify the developer's own
work) transfers from universe to place. Places already declare both
description and displayName on their entry shape, so the same default set
applies cleanly.
| Kind | Redacted by default | Override field set |
|---|---|---|
gamePass |
name, description, icon |
{ name?, description?, icon? } |
developerProduct |
name, description, icon |
{ name?, description?, icon? } |
badge |
name, description, icon |
{ name?, description?, icon? } |
place |
description only |
{ description?, displayName? } |
universe |
(none today; see notes) | { displayName? } once wired |
Universe-level redaction is deferred until either the icon endpoint lands
upstream (resolves the icon half) or the design migrates to publishing
displayName overrides through the existing universe path. The redacted field
on UniverseEntry is not yet present in the schema and will be added when there
is a concrete redactable target.
| Kind | Field | Default |
|---|---|---|
gamePass |
name |
"Redacted Pass" |
gamePass |
description |
"" |
gamePass |
icon |
embedded placeholder PNG |
developerProduct |
name |
"Redacted Product" |
developerProduct |
description |
"" |
developerProduct |
icon |
embedded placeholder PNG |
badge |
name |
"Redacted Badge" |
badge |
description |
"" |
badge |
icon |
embedded placeholder PNG |
place |
description |
"" |
place |
displayName |
(no default; explicit override required) |
Two changes to the developerProduct name default since the 2026-05-16
amendment:
- Hashed suffix (PR #426). The bare shared default
"Redacted Product"collided with Roblox's per-universe uniqueness constraint on dev-product names. The default now embeds a 6-hex SHA-256 digest of the resource key so every redacted entry resolves to a unique wire value. - Prefix change to
"Hidden Product"and removal of the#separator. Live smoke testing against Anime Rush surfaced that Roblox's text moderation filter silently replaces some names matchingRedacted Product #<hex>with########################(24 hashes, one per source character). Once one product's name moderates to that string, every other redacted entry whose proposed name would also moderate to the same string is rejected at PATCH time withDuplicateProductName(Roblox's uniqueness check runs against the post-moderation value, not the submitted one). Dropping the wordRedactedand the#separator produces names of the formHidden Product <hex>that the moderation filter has been observed to pass cleanly.
The gamePass default "Redacted Pass" is unchanged: game-pass names do not
enforce per-universe uniqueness on Roblox, so a moderation-induced collapse to
######################## cannot cascade into apply failures.
The prefix swap is a stop-gap: the moderation filter is opaque and the pattern that passes today could stop passing tomorrow. Sturdier options (post-PATCH moderation-drift detection, moving uniqueness off the human-readable name, pre-validating against a Roblox filter endpoint) are tracked in issue #431.
| Kind | Field | Default |
|---|---|---|
developerProduct |
name |
`Hidden Product ${suffix}` |
Other rows are unchanged from the 2026-05-16 table above.
Four coupled changes: three lift restrictions the original Decision placed on the redaction surface, and one bumps the default for the price field added in #427.
REDACTED_PRICE becomes 99999. The original Decision's Placeholder defaults
table did not cover price;
#427 introduced price
as a redactable field with an implicit default of 1. The bump is a fail-safe
shift. A misconfigured redacted resource erring on "unbuyable" is preferable to
one erring on "accidentally cheap": the former is embarrassing, the latter risks
revenue loss and premature reveal of monetization metadata.
Off-sale resources (price: undefined) continue to stay off-sale through
redaction; the default applies only when a real price is set.
| Kind | Field | Default |
|---|---|---|
gamePass |
price |
99999 (when on-sale) |
developerProduct |
price |
99999 (when on-sale) |
Other rows are unchanged from earlier tables.
The "Env-level override object" alternative is no longer deferred. A concrete
use case has surfaced: an author needs every redactable product and pass in an
env to ship with { price: 1 } (or another uniform override) without repeating
the override per resource.
environments.<env>.redacted accepts
boolean | RedactedEnvironmentOverride | undefined, where
RedactedEnvironmentOverride carries the union of redactable fields across
kinds:
| Field | Applies to |
|---|---|
name |
gamePass, developerProduct |
description |
gamePass, developerProduct, place |
icon |
gamePass, developerProduct |
price |
gamePass, developerProduct |
displayName |
place, universe (when explicitly redacted via override) |
Per-kind application: each redactable kind picks up only the fields its own override type supports. Fields a kind does not recognize are silently ignored for that kind. The "kind-specific field eligibility" concern raised in the original Alternatives Considered section is resolved by this rule.
Each per-resource entry inside an env overlay
(environments.<env>.passes.<key>, .products.<key>, .places.<key>) accepts
the kind's existing override object form alongside boolean. The env-overlay
surface aligns with the root resource entry, removing the public-API asymmetry
where the exported RedactedGamePassOverride,
RedactedDeveloperProductOverride, and RedactedPlaceOverride types appeared
usable wherever redacted was mentioned but were rejected at env scope. Covers
user story 17 from the PRD
(#408).
Layers compose field-by-field rather than whole-object. The original Precedence section's "most-specific layer wins" reading was consistent with both whole-object and field-level merging because only one layer (root-resource) carried object form; the question had no observable answer. With object form at every layer the choice is explicit.
Layer order remains most-specific to least-specific. Short forms below are convenient nicknames used through the rest of this amendment:
env-resource (per-resource inside an env overlay)
> root-resource (per-resource at root)
> env-level (cross-kind on the env entry)
Resolution is two-step:
- State. The first non-undefined
redactedvalue sets the redaction state.falsecarves out: the resource is not redacted and lower layers are moot.trueor object form enables redaction; proceed to step 2. - Fields. Walk every object-form layer encountered (most-specific to
least-specific). Merge fields with the most-specific layer's value winning
per field. Boolean
truecontributes no fields. Bedrock kind defaults fill any field still unset.
Canonical example: products.myProduct.redacted = { name: "Hidden" } at root
combined with environments.dev.redacted = { price: 1 } at env-level resolves,
for dev's myProduct, to { name: "Hidden", price: 1 } plus kind defaults for
description and icon.
The Negative section clause about authors needing to "repeat the override on each env overlay's entry" is superseded: root-level overrides now compose with env-level and env-resource overrides automatically.
The original Decision (State and diff section) recorded that "the state file
records the values that were actually pushed -- placeholders for redacted
resources", and the "Encrypt or omit real values from the state file"
alternative was rejected with the reasoning that real values "stay in config".
That holds for the diff and apply paths, but it leaves a gap for codegen
(ADR-026): an emitter that writes real names/prices/descriptions into generated
game source reads from State, not config, and State carried only the
placeholders. Identity (asset IDs in outputs) already flows to codegen because
outputs are never redacted; the gap is the display values.
This amendment is a deliberate, scoped reversal: the real (pre-redaction) display values for a redacted resource are now persisted in the state backend, alongside the placeholders, so codegen can recover them.
Each redacted resource gains a namespaced, diff-ignored sibling in the state
file. On disk it is co-located as a $realDisplay key on the resource object
(adapter-private, like the $bedrock envelope); in memory it is a
BedrockState.realDisplay map keyed by the kind:key composite the diff uses.
serializeStateFile / parseStateFile own the on-disk ↔ in-memory mapping;
diff and the state merge operate only on the resources array and never read
the sibling. The scalar pushed fields, the per-kind drivers, and the diff
algebra are unchanged, so the diff stays redaction-blind: a redacted resource
whose real values change but whose pushed values do not still diffs as a noop.
The sibling carries only the redactable scalar fields that diverge from the
pushed value (name, description, price; place displayName /
description); icon is excluded because its actionable value is the asset ID
in outputs.
The polymorphism lives only in the codegen-facing view, never on the persisted
or diffed resource. codegenView(resource, realDisplay) presents each
redactable field as Field<T> = T | { readonly value: T; readonly redacted: T }
— a plain scalar when the field was not hidden, the object form (real value
plus redacted placeholder) when it was. The exported realValue,
pushedValue, and isRedacted helpers narrow the union so emitters never
hand-narrow it.
This narrows the original threat model's escape hatch into a requirement. The
original Decision noted that "authors who need state-file confidentiality should
choose a locked-down state backend"; persisting real pre-release values makes
that mandatory for redaction-plus-codegen. The state backend now holds the
exact monetization metadata redaction hides from the Roblox page, so it must be
a confidential store (e.g. a private gist) — the same trust boundary the backend
already required for resource IDs, now load-bearing for display content too. The
on-Roblox threat model is unchanged: placeholders are still what ships to Open
Cloud. No state-file schema bump: realDisplay is an optional, v1-compatible
addition (see ADR-019's 2026-06-23 amendment); older readers tolerate it and a
happy-path (no redaction) file never carries it.