Skip to content

fix(pm): stop T1 reading a typed parameter, and a re-spelled closed set, as a new key - #17760

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-17618-widening-tells-t1-scope
Sep 12, 2026
Merged

os-sales merged 1 commit into
mainfrom
claude/issue-17618-widening-tells-t1-scope

Conversation

@claude

@claude claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #17618

What was wrong

check-widening-tells' T1 tell reads an added line shaped identifier: z.Something on the contract source surface as "a new key on a Zod object schema — the accept set gains a spelling an author may now write". Two live pairs raised it against diffs that spell nothing new. Both were reproduced from the PRs' own pushed bytes before anything was written.

Instance 1 — PR #17616 (packages/spec/src/ui/dashboard.zod.ts:470). The row fires on ctx: z.RefinementCtx,, the second parameter of an exported object-level refinement. z.RefinementCtx is a type; nothing constructs a shape there, and the diff it appears on refuses metadata that parses today. That signature is this repo's own prescribed one (the #16489 convention — checkListViewPageMount, checkPageSourceCompleteness, checkGlobalFilterDateDefaultValue), so every diff adding a cross-field refusal raised a widening tell for the refusal itself.

Instance 2 — PR #17638 (packages/spec/src/system/cache.zod.ts:197). The row fires on strategy: z.enum(['eager', 'lazy'], {, an in-shape key the same change block removed as strategy: z.enum(['eager', 'lazy', 'scheduled']).default('lazy'). Same key, one member fewer. That pair declares Clause-②: no, so it exited 4 and held a reviewed, green retirement PR out of the queue, where the only sanctioned clear is the false yes the file already refuses to ask an author for.

The measurement that picked the fix

Instance 2 is not the #16943 net-delta budget being too thin. The budget was earned — the removed strategy: line is itself T1-shaped and bought one T1 unit — and then refused at the spend, by !CLOSED_SET_OPENER.test(text): "a line that DECLARES a closed set is never spent against the budget". That clause was written about an opener, and an opener never reaches it: memberTellKind already answers null for an opener-only line. So the only lines it ever caught were keys whose value opens z.enum( / z.union( / z.discriminatedUnion( / z.literal(. Measured on this tree with the pre-change reader:

field: z.string()          -> field: z.string().optional()          0 row(s)   (declines)
kind:  z.enum(['a'])       -> kind:  z.enum(['a']).optional()       1 row      (fires)

The asymmetry was accidental.

What changed (scripts/pm/check-widening-tells.mjs, one file)

The spend. What the blanket refusal was protecting is real and is kept: an inline set has no per-member line for T2 to read, so a set widened in place is visible on the T1 row and nowhere else. A closed-set-valued key may now spend the budget only on three facts the block carries — a removed line naming the same key, both member lists readable on their own line, and the added list a subset of the removed one. z.enum(['a', 'b']) to z.enum(['a', 'b', 'c']) still fires; a list that opens on a later line is unreadable and still fires; another key's removal pays nothing.

The parameter. The claim is deliberately smaller than the depth-aware z.object({ … }) reader T1's own comment refuses — the one whose cheap version fails GREEN by truncating. A Zod shape body is {-delimited by construction, so the question is never "which shape is this line in" but "which bracket is innermost", read over the line's own hunk, plus a declaration head left of that paren. Every state the reader cannot carry honestly answers nullkeep the tell firing: a closer arriving on an empty stack (the hunk began inside something it was never shown), a string literal that does not close on its line, a declaration head it does not recognise (a method shorthand, for one). Nothing it returns ever means "no longer inside a shape", which is why it has no truncating failure mode. The same reading is applied on the old side too, the way #16822's fragment rule is: a deleted parameter must not buy an added key the right to go unreported.

Price of the change, measured

Over the 233 commits touching these surfaces in this tree's history, A/B'd row-for-row between the pre-change reader (origin/main e9efc403) and this one:

rows
tell rows the previous reading raises 20,193
the new reading raises 20,170
now decline 23 — all T1; no T2, T3 or T4 row moves

Of the 23: 15 are parameters (twelve ctx: z.RefinementCtx / z.core.$RefinementCtx, three input: z.input[typeof SomeSchema]) and 8 are existing keys re-spelled to carry .meta({ title }) or a rewritten .describe() around an identical enum (e.g. d2badf723, f502898a4). Not one is a key or a member its diff added. On the tree itself, 16 of 8,974 T1-shaped lines under packages/spec/src/** sit inside a parameter list, 10 of them annotated z.RefinementCtx.

The file's header prices this in the register it already uses, and states both quiet directions rather than leaving them to be discovered:

  1. A parameter added to an already-exported function is a signature widening and now goes unreported here; nothing else in the file catches it (T3's listing records that an export EXISTS, and api-surface-signatures.json carries 27 define* helpers, none of them one of these checks). What is not lost is the function itself — a newly exported check adds its own T3 row, which is why fix(spec)!: refuse dashboard.widgets[].options.stageOrder on every widget type that does not read it #17616 still reports one.
  2. A widening carried by the chained methods rather than the member list (.optional() first among them) now declines on a closed-set-valued key. Not a new class: [finding] check-clause2-carriers T2 fires on a replaced string property value as "a new member of a closed set", and the C5 row it raises cannot be cleared except by declaring Clause-② yes on a change that does not widen #16943 already declines it for every key whose value is not a closed set; this removes the accidental exception rather than adding one. In the measured population all eight re-spellings are .meta / .describe rewrites and none adds .optional().

Acceptance (grading comment 5642818369)

item before after
the parameter-list line reads no tell T1 at dashboard.zod.ts:470 no row
the in-shape re-spelling with a shrinking set reads no tell T1 at cache.zod.ts:197 no row
a genuinely new key on a shape STILL tells (control) manifest.zod.ts:44 manifest.zod.ts:44
the #16489 signature as a named fixture FILE_REFINEMENT_SIGNATURE in --self-test
--pair 17638 with its Clause-②: no unchanged exit 4, one T1 row exit 0, no tell
$ node scripts/pm/check-clause2-carriers.mjs --pair 17638
check-clause2-carriers: PR #17638 / card #17157 — the clause-② declaration is readable in the fixed
spelling and both carriers agree, and its diff carries no widening tell.   (exit 0)

⚠️ One acceptance item could not be run as written, and the reason is a fact about the board, not about the tree. --pair 17616 cannot form a pair: PR #17616 merged at 2026-09-11T07:41Z, and the carriers script only judges an open PR — it exits 2 ("PR #17616 is not open"), both before and after this change, which is not a T1 reading in either direction. Instance 1 is therefore reproduced and pinned where the reading actually lives: tellsInFile over that PR's real pushed patch (the bytes are the --self-test fixture), and the merged commit 1f0b5659e is one of the 23 declines in the history A/B above.

Verification

  • node scripts/pm/check-widening-tells.mjs --self-test269 cases pass (245 before; +24 in the new battery, registered in the roster so the floor cannot be silenced by deleting it).
  • node scripts/pm/check-clause2-carriers.mjs --self-test465 cases pass (it imports this file).
  • Gate families derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on the final commit and reconciled with --ran; every derived command run, exit codes in the report.
  • The decline is bracketed on every side by controls that still fire — an inline enum widened in place, a different key carrying a subset set, a list that opens on a later line, a real key added after the parameter list closes — because a reading that can only suppress is untestable in the direction that matters.

Gate record — run on the final commit 923aed38a

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 32 families for the one changed path; all 32 were run and all 32 exited 0, then reconciled with --ran (each line recorded as command :: exit code, captured before any pipe):

Run reconciliation — 32 derived, 32 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 32 derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED
  (a DERIVED zero — all 32 recorded an exit code and none of them is 3).

Beyond the derived families, the two this diff owes by kind: pnpm check:pm-widening-tells (this file's own --self-test, which is what lint.yml runs) and node scripts/pm/check-clause2-carriers.mjs --self-test (the importing sibling) — both exit 0 and both are inside the 32. git grep finds no *.test.ts naming this script; its suite IS the self-test.

Lint is delivered as a proven narrowing, not skipped: (i) the population is read from eslint's own config — the base entry's files glob is the whole tree (**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}), which covers this path; (ii) npx eslint --no-inline-config --format json scripts/pm/check-widening-tells.mjs reports 1 file, 0 errors, 0 warnings (count read from the JSON, exit 0); (iii) invariance — this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules; the config says so at eslint.config.mjs:326), so a one-file diff cannot move the verdict on any file it does not touch. The whole-repo eslint . --no-inline-config remains CI's run.

The one judgment call, on the four axes

The card offered two shapes and this PR takes the smaller one. 实际业务需求: the population is measured, not speculative — 233 commits, 23 rows, and one PR currently blocked by the instrument. 项目长远合理性: the reading adds one bracket fact a hunk genuinely carries and refuses the depth-aware shape reader whose cheap version truncates; null means "keep firing" everywhere, so it cannot degrade silently (Route & surface ownership §3). 防 AI 写错: the defect pushed a seat away from tightening a contract — it reported the narrowing direction as the widening one — which is the inverse of what clause ② exists to catch; correcting it makes the strict direction the cheap one. 创业阶段不扩散: one file, no new flag, no new label, no new claim-line syntax, and no exit code moves.

Acceptance notes

Authored by the skills-lane dev seat, session session_01MCLBsUgfykL74aU716rzVK, on claim comment 5642827410.


Generated by Claude Code

…et, as a new key

`check-widening-tells`' T1 tell fires on any added line shaped
`identifier: z.Something` on the contract source surface. Two live pairs
raised it against diffs that add no key at all:

- PR #17616 — `ctx: z.RefinementCtx`, the second parameter of an exported
  object-level refinement. That signature is the repo's prescribed one, so
  every diff adding a cross-field refusal raised a widening tell for the
  refusal itself.
- PR #17638 — `strategy: z.enum(['eager', 'lazy'], {`, an in-shape key the
  same block removed with one member MORE. Exit 4 against a correct
  `Clause-②: no`, holding a reviewed retirement PR out of the queue.

The second was the net-delta budget being EARNED and refused at the spend:
"a line that DECLARES a closed set is never spent" was written about an
opener, which `memberTellKind` already answers `null` for, so the only lines
it caught were keys whose value opens a closed set. It is replaced by the
thing it protected — an inline set widened in place is visible on the T1 row
alone — so such a key spends only on three facts the block carries: a removed
line naming the same key, both member lists readable inline, and the added
list a subset of the removed one.

The parameter half reads which bracket is innermost over the line's own hunk;
a shape body is brace-delimited by construction, and every state the reader
cannot carry honestly answers "keep firing".

Measured over the 233 commits touching these surfaces (`e9efc403`): of 20,193
rows the previous reading raises, 23 decline and 20,170 stand — all 23 T1,
fifteen parameters and eight existing keys re-spelled around an identical
enum.

Claude-Session: https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK
Co-authored-by: Claude <noreply@anthropic.com>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 12, 2026
@os-sales
os-sales marked this pull request as ready for review September 12, 2026 03:09
@os-sales
os-sales added this pull request to the merge queue Sep 12, 2026
Merged via the queue into main with commit cb0c616 Sep 12, 2026
46 of 48 checks passed
@os-sales
os-sales deleted the claude/issue-17618-widening-tells-t1-scope branch September 12, 2026 03:34
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…rding it (objectstack-ai#17766)

Fixes objectstack-ai#17149

A `Claim:` comment that parses to ZERO branches is now reported as a
MALFORMED claim instead of being discarded. The deliverable is triage
5620657752's, verbatim: 「⛔ do not fix the third spelling. **A parser
that silently yields zero must say so** — an unparsed claim is an
*unclassified* result, ⛔ never a *no*.」 Option 1 on the card (widening
`claimedBranches` for the inline spelling) is **not** done here, and the
branch reader's accept set is byte-identical — pinned as a case, because
that is what keeps the NEXT unrecognised spelling loud on its first
occurrence.

## The mechanism

`governingClaim` picked the newest claim comment from which at least one
branch parses, and `if (branches.length === 0) return;` threw the rest
away. A discard is indistinguishable from an absence, so governance fell
back to an older claim and said nothing.

`claimGovernance(commentRows)` is now the two-part reading:

- `governing` — what `governingClaim` has always returned.
`governingClaim` is a one-line wrapper over it, so the ~18 readers that
legitimately need only branches are untouched (proved below: the
self-test's 3616 pre-existing cases pass unchanged on the refactor
alone).
- `malformed` — the NEWEST claim-shaped comment when it parses to zero
branches, carrying its comment id, its timestamp, and the `created_at`
of whatever governance fell back to.

Recency is written once and both halves call it, so the two readings can
never disagree about which comment is current.

## Every reader says so

| reader | before | after |
|---|---|---|
| H20 / H27 dispatch liveness | probed the superseded branch, or
(nothing parses) went silent as "no claim" | **H60**, a new `state`-band
row, fires for every open `pm:dispatched` card, naming the comment id,
what governance did instead, and the remedy |
| `check-clause2-carriers` declaration limb | read the superseded
claim's `Clause-②` line as if current | `cardDeclaration` returns
`claim-branch-unparsed` **before** any line is read, from any comment
(including the objectstack-ai#17366 correction path) |
| `--pair N` | exit 4 with a verdict taken from the wrong comment |
**exit 2 (UNJUDGED)**, with the whole reading printed |
| the fold/lane rosters (H37/H38) | built on `governing` | unchanged —
they need branches only, and H60 is the row that says the roster may
rest on a superseded claim |

**The exit code is the file's own, not a new one.** Its table already
reads 2 as UNJUDGED — "an unread carrier is NOT a bare carrier and an
unread thread is NOT an absent declaration (objectstack-ai#4690)" — and an
unresolvable carrier is exactly that. Rendering it as 4 would make an
unclassified result an adverse verdict, which is the reading the card
refuses by name. Rendering it as 0-with-a-message is what the whole file
exists against.

## The three questions the card asked

### 1. Where does the guard belong — carriers, half-states, or both?

**Both, from one source.** The state is produced once in
`check-half-states.mjs` (which owns `CLAIM_COMMENT_MARKER` and
`claimedBranches`) and consumed in both files. A second detector in the
carriers file is the drift that file's own docblocks refuse by name
("imported rather than restated ... so the two readers cannot drift").

### 2. Which end?

Option 2 only. See the four-axis reading below.

### 3. Is the fleet's claim template the cause? — MEASURED on the live
board

Read 2026-09-12T03:4xZ, REST, repo-scoped, over every open
`pm:dispatched` card:

- **28** open `pm:dispatched` cards.
- **5** of them (18%) have a newest claim-shaped comment that parses to
ZERO branches: **objectstack-ai#16310, objectstack-ai#16268, objectstack-ai#16251, objectstack-ai#16175, objectstack-ai#15234**.
- 22 parse; 1 (objectstack-ai#13597) carries no claim-shaped comment at all.

Four of the five claims were posted by ONE session
(`session_012GKcPZbMoGq7WPzKLfRBTU`, the `domain:devx` execution PM
seat) within **three seconds** of each other, 02:53:05Z–02:53:08Z, all
in the same shape:

```
Claim: session_012GKcPZbMoGq7WPzKLfRBTU · claude/issue-16310-orphan-locale-key-gateable
Clause-②: yes
```

That is not a run of typos; it is a template emitting a carrier no
reader accepts. **objectstack-ai#16175 is the silent-fallback shape, live today**: its
newest claim (5642984850, 2026-09-12) parses to zero, so governance
falls back to its 2026-09-06 claim (5557414924) — which names a
**different** branch, `claude/issue-16175-regen-sibling-stale-rules`
against the current `claude/issue-16175-staleness-mtime-false-refusal`.
Every downstream reader is probing the wrong ref, and the two `Clause-②`
values happen to AGREE, which is the objectstack-ai#16589 near-miss recorded on objectstack-ai#16322
reproducing itself.

⛔ No `.claude/**` edit is made from this card, per the dispatch. The
template finding is handed to the skills-lane seat; after this PR lands,
those five cards are visible rather than silent, which is the point.

## Four-axis reading of option 1 vs option 2

- **实际业务需求** — measured, not speculative: 5 live carriers today, plus
the objectstack-ai#16322 cost (two rounds, a director re-review, a re-issued claim)
and its near-miss sibling. Option 1 addresses the one spelling in front
of us; option 2 addresses the population that produces them.
- **项目长远合理性** — option 1 is the treadmill the card names and objectstack-ai#16170
already bought once; each widening buys one spelling and leaves the next
silent. Contract-first says the defect is at the reader's CONTRACT (a
two-valued answer where three states exist), not in its accept set.
- **防 AI 写代码犯错** — decisive here. Option 1 is consumer-side tolerance —
the exact shape the axis forbids, and its failure mode is the silent
one. Option 2 is a loud refusal at read time: a claim written in a shape
the protocol does not accept is refused with the remedy named, and the
seat cannot declare a claim the tooling does not honour.
- **创业阶段不扩散需求** — option 2 adds no capability surface: one new reading
of data already in hand, no new request, no new exit code, no new label
written. Option 1 would grow the accept set permanently for a spelling
the standing rule already forbids.

⇒ Option 2, on all four. Option 1 is deliberately NOT also done: doing
both would let the accept set absorb the measured spelling and leave the
new state unexercised on the live board, which is the one way to ship
this fix and still not know whether it works.

## Verification

All exit codes captured before any pipe (`cmd > log 2>&1; EXIT=$?`).

| command | verdict line | exit |
|---|---|---|
| `pnpm check:pm-half-states` | `✓ check-half-states self-test: 3656
cases pass.` (was 3616) | 0 |
| `pnpm check:pm-clause2-carriers` | `✓ check-clause2-carriers
self-test: 493 cases pass (...)` (was 465) | 0 |
| `pnpm check:pm-dispatch-gates` | `✓ dispatch-gates self-test: 1678
cases pass.` | 0 |
| the derived union, 41 commands | `✓ dispatch-gates --ran: 41 derived
famil(ies) accounted for — 41 run, 0 NOT-MEASURED (a DERIVED zero — all
41 recorded an exit code and none of them is 3).` | all 0 |

The union was re-derived and re-run **after** the final commit, on head
`b2b55c2b4` (`git rev-parse --short HEAD`), with `--repo
objectstack-ai/objectstack` asserted. The first derivation printed a
STALE TREE clause naming `check-skill-line-ratchet.mjs` and
`check-widening-tells.mjs` (PR objectstack-ai#17760 had landed); `origin/main` was
merged in, the list re-derived byte-identically at 41, and every command
re-run on the merged head. Reconciliation was fed `command :: exit N`
lines so the zero is derived, not claimed.

⚠️ NOT MEASURED, and named rather than implied: the 47 artifact-roster
families, the 11 declared-wide-population families, the 4 families
taking a value from the workflow, and the 1 path-scheduled CI job are
each outside the derived total — CI's, not this run's.

### Ablation — the new state can actually fail

Committed first, then mutated on disk, then restored; the mutation and
the restore are both proved by `git hash-object` against the HEAD blob
rather than by an exit code.

- Mutation: the pre-fix silent discard restored (`malformed` never
populated). Anchor occurrences 1 → 0, file hash `f9f869cd` → `013b485b`.
- Result: **20 of 3656** half-states cases and **14** carriers cases
turn RED, every one of them in the new batteries. Direction: turns red,
as pre-registered.
- Restore: `git checkout HEAD -- scripts/pm/check-half-states.mjs`; `git
diff HEAD` empty, hash back to `f9f869cd`. The script carries `trap ...
EXIT INT TERM`.

## Live verification — the fix, run against the real board

Both sweeps were run on the merged head, 2026-09-12T04:0xZ.

**`check-half-states.mjs` (sweep, exit 0)** emits exactly **5** H60 rows
— the same five cards the independent REST census found, arrived at
through the fix's own code path: objectstack-ai#15234, objectstack-ai#16175, objectstack-ai#16251, objectstack-ai#16268, objectstack-ai#16310.
Both sentence variants fire live: objectstack-ai#15234 gets the "NOTHING governs this
card" reading, objectstack-ai#16175 gets "governance SILENTLY FELL BACK to an OLDER
claim (2026-09-06...)".

**`check-clause2-carriers.mjs` (sweep, exit 2)** reports **5 of 18
card/PR pairs UNJUDGED** — and two of them (objectstack-ai#15627 via PR objectstack-ai#17776, objectstack-ai#16565
via PR objectstack-ai#17310) are cards the `pm:dispatched` census could not see,
because they are not in that population. Before this change the sweep
exited 0 on all of them.

**The before/after, measured rather than reasoned** — the base tree at
`813f8e9f` materialised with `git archive` and its `cardDeclaration` run
against the same live comment rows:

| card | BEFORE | AFTER |
|---|---|---|
| objectstack-ai#16565 | `declared` `no` (`"Clause-②: no"`) | `claim-branch-unparsed`
|
| objectstack-ai#16175 | `declared` `no` (`"Clause-②: no"`) | `claim-branch-unparsed`
|
| objectstack-ai#15627 | `missing` | `claim-branch-unparsed` |

Two of the three read as a confident `Clause-②: no` taken off a comment
whose governance was never established — the objectstack-ai#16589 near-miss shape,
twice, on today's board. The third read as `missing`, which sends the
seat looking for a declaration line that is already written. Neither was
a reading anybody could have found without opening the card by hand.

## Fixtures

The measured bodies are quoted, never paraphrased, so a future widening
cannot make these batteries pass by accident:

- objectstack-ai#16322's two claims (5593513389 `Clause-②: no`, 5594909614 `Clause-②:
yes`) — the inline spelling, and the two values DISAGREE, which is why
reading the wrong one was a wrong answer and not merely an unlucky one.
⚠️ Re-measured: the OLDER claim is branchless too, so on that card
nothing parsed at all and `cardDeclaration`'s `pool = claimRows`
fallback read the FIRST claim in thread order — a second silent fallback
the card did not name, and the same fix reaches it.
- The live 2026-09-12 specimen on objectstack-ai#16175, with its different-branch
fallback.
- objectstack-ai#16170's bulleted directive still parsing (its pin stays green,
untouched at the H20 branch battery).
- Controls: a well-formed newest claim still governs; an older
branchless claim beside a well-formed newest one raises nothing; no
claim comment at all still reads `absent`; an unreadable thread still
reads `unreadable`.
- The negative pins that keep this a STATE: the inline spelling still
parses to ZERO, and `CLAIM_COMMENT_MARKER` still matches it.

## Acceptance notes

- `skip-changeset` applies: `scripts/pm/**` ships in no package
(fast-track path, no measurement owed).
- `Clause-②: no`, as the claim declares — nothing published moves.
- Scope held: no H22 docblock prose (objectstack-ai#17626 owns it), no widening tells
(objectstack-ai#17618), no `.md`, no `.claude/**`.
- H60 takes the `state` band, beside H34 — H34 reads a claim whose
SEPARATOR the marker refuses, this one reads a claim whose BRANCH line
the directive reader refuses; both are a live card contradicting itself,
repaired on the board. ⛔ Not `stall`: the row does not claim the card is
stopped, and the dev may be working perfectly well — what is broken is
the READING.
- ⛔ No age gate on H60, unlike H20's 60 minutes: a branchless claim is
wrong at the instant it is posted and no later sweep frees it, because
the protocol forbids a second `Claim:`. Pinned in both directions.
- noted, not filed: `cardDeclaration`'s `pool = claimRows` path (used
when nothing on the thread parses) picks the FIRST claim comment in
thread order rather than the newest. This PR makes that path unreachable
for the branchless case, so the residue is a recency question on a path
no live shape now reaches. Successor: whoever next touches
`cardDeclaration` — objectstack-ai#17098 is open against that function's
neighbourhood.

---

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant