Repository navigation
Ledger convergence: registration home + one store implementation + permission-set convergence (ADR-0126 §4/§8, maintainer-ruled bundle) #12159
Description
Activity
- changed the title
[-]L8: permission-set convergence onto the generic activation ledger — projection vs migration (ADR-0126 §8.3)[/-][+]Ledger convergence: registration home + one store implementation + permission-set convergence (ADR-0126 §4/§8, maintainer-ruled bundle)[/+]on Aug 26, 2026 os-support-ai commented
on Aug 26, 2026 CollaboratorAuthorMore actionsClaiming — dispatched under the #12150 program (standing autonomy ruling, maintainer, 2026-08-25, verbatim: 「继续自主完成所有开发」; bundle ruling 2026-08-26, verbatim: 「同意」).
- Session:
session_01KWRU3s15AJz7PGW7a7wdCh(PM session; implementation delegated to an os-dev agent under this session) - Branch:
claude/issue-12159-ledger-convergence - Unlock condition satisfied: original Blocked-by L1:
sys_metadata_activationplatform object — the activation ledger (ADR-0126 §4) #12155 merged long since (428f9b24); both store copies this card consolidates are merged (feat(automation): durable packaged-flow disable — activation ledger, execute() consult, operator gate #1229615249270, feat(actions): durable packaged-action disable — same activation ledger, dispatch-time consult #12348e5ce2ed0).
Generated by Claude Code
- Session:
os-support-ai commented
on Aug 26, 2026 CollaboratorAuthorMore actionsPart 3 — decision note, and a STOP
Posted before any Part 3 code, as the card requires. Verdict: stop-and-report. The measurement says the choice has customer-visible consequences either way, which is the card's own trigger for not choosing on the maintainer's behalf. Parts 1 and 2 are implemented and delivered; Part 3 carries no code.
All numbers below are from a real boot —
bootStack(showcaseStack), rows read through the engine under a system context — not from reading source.What is actually in
sys_permission_settotal = 17 managed_by = { platform: 8, package: 9 } with package_id = 9 (so 8 of 17 carry NONE) inactive = 0 org-carrying = 0 indexes = [ {name, unique:'organization'}, {active}, {package_id} ] listViews filtering on `active` = [ 'active', 'inactive' ] sys_position rows = 16, with package_id = 0 sys_capability rows = 10, with package_id = 2 sys_metadata_activation: package_id required = true indexes = [ {metadata_type,name, unique:'organization'} ]And the #11513 remedy path, driven end-to-end through the real data door:
POST /api/v1/data/sys_permission_set (clone) -> 201 row = { managed_by:'admin', package_id:null, active:true, organization_id:null } PATCH /api/v1/data/sys_permission_set/{id} {active:false} -> 200Route A — migration (rows move, column retired)
Customer-visible, and unsound on two independent axes:
- Subject mismatch. 8 of 17 rows on a stock boot carry no
package_id, and every clone — the exact remedy the packaged-set lock names — adds another (managed_by:'admin',package_id:null, measured above). The ledger'spackage_idisrequired: trueand its subject is packaged artifacts (ADR-0126 §4). Migration would have to relax a required column or invent package ids for artifacts nobody packaged. - Row-identity mismatch.
sys_permission_setisunique:'organization'onname, so two organizations may each holdsales_readonlywith differentactivevalues (Five more instances of the #8323 class: admin- and user-authored names on tenant-scoped objects still carry installation-wide unique indexes #8554 measured exactly that pair live). The ledger'sorganization_idis RESERVED and unwritten (§5), and the store SKIPS org-carrying rows on read. Collapsing per-org sets onto one install-level row is the Decide whether POST /api/v1/automation/:name/toggle belongs in the manage_metadata write set — it mutates flow enablement with no authoring capability #10243 cross-tenant leak the ledger exists to retire, arriving from the other direction. - Surfaces bound to the column, each customer-facing: a declared index
{fields:['active']}; two Setup list views filtering on it (active/inactive) and a third displaying it;activate_permission_set/deactivate_permission_setPATCHing/api/v1/data/sys_permission_set/{id}withbodyExtra:{active};clone_permission_setsendingactive:trueon create; the explain panel'sdeactivatedverdict (The explain engine has no contributor state for a DEACTIVATED permission set — it just vanishes, where an expired grant says "held until … — expired" #8714). - Read-path cost.
activeis read as a COLUMN off rowsresolveAuthzContexthas already fetched, via the sharedisRowActivepredicate — zero extra reads on the hot authorization path. A ledger-backed answer inserts a second store into authorization.
Route B — projection (column stays, ledger reads through)
Splits into two halves, and neither is the convergence §8 item 3 asks for:
- Read-through only (a
metadata_type:'permission'adapter over the column) changes nothing a customer can see — and delivers nothing: it adds a second way to ask one question. That is the "one operation, two implementations" shape ADR-0126 §4 and Two implementations of the ADR-0126 §4 activation-ledger row contract (flow + action) — consolidate onto one #12350 exist to remove, so it is convergence spelled backwards. - Write-through (making the ledger's
setActivea real door for'permission') is customer-visible: it becomes a second write path tosys_permission_set.activethat bypasses the permission-set backfill (ADR-0094 D4) 现在 100% 失败:行里的active存储列喂进了 #4001 之后严格化的 permission spec #4669 row-state carve-out ordering and the Studio save of a package-declared permission set forks it into a silent, undiscoverable overlay #11513 packaged-set lock pre-pass inpermission-set-projection.ts. The card requires landed Studio save of a package-declared permission set forks it into a silent, undiscoverable overlay #11513 behaviour to be unchanged from the customer's seat; this changes it.
The family the card does not mention
sys_position.active(16 rows, 0 packaged) andsys_capability.active(10 rows, 2 packaged) are the same ADR-0049 switch, judged by the sameisRowActivepredicate. Converging only the permission member splits a three-member family — and the other two are less packaged than this one, so whatever answer fits here has to be argued for all three.Four-axis reading
- Real business need — no measured pull. Zero inactive permission-set rows on a stock boot; the
activeswitch that IS exercised is the Studio save of a package-declared permission set forks it into a silent, undiscoverable overlay #11513 clone/deactivate loop, which works today and is unrelated to packaged-artifact activation. - Long-term soundness — the ledger's declared row identity (install-level, packaged,
(metadata_type,name)) and this column's (per-org, mostly unpackaged, per-record) are different contracts. Route A bends the ledger's; route B duplicates a question. Neither is contract-first. - AI-written-code safety — a second write door for the same bit (route B's write half) is precisely where an AI-authored admin app would flip a set without passing the packaged-set lock. Today there is one door.
- Startup scope discipline — this is an unpulled convergence over a landed, working surface, on a three-member family the card scopes to one member.
What I recommend the maintainer decide
Three options, in the order I would rank them:
- Decline the convergence and record why (recommended). Amend ADR-0126 §8 item 3 to say the row-state door stays where permission-set backfill (ADR-0094 D4) 现在 100% 失败:行里的
active存储列喂进了 #4001 之后严格化的 permission spec #4669 put it, with the measurement above as the ground: the ledger is for packaged artifacts at install scope, andsys_permission_set.activeis neither. Costs nothing, closes an open item honestly. - Re-scope it to the family — one card covering
permission/position/capabilitytogether, starting from "what isactiveFOR on a catalogue row", and only then asking whether any of it belongs in the ledger. - Route A anyway, accepting a data migration for unpackaged rows, a per-org dimension the ledger has not chartered (§5), and changes to the Setup views and the two lifecycle actions. I do not recommend it on any of the four axes.
Parts 1 and 2 are in the PR; Part 3 is
stopped-reportedthere and needs no rework to land whichever way this goes.
Generated by Claude Code
- Subject mismatch. 8 of 17 rows on a stock boot carry no
os-support-ai commented
on Aug 26, 2026 CollaboratorAuthorMore actionsos-dev-report
{ "issue": 12159, "status": "done", "branch": "claude/issue-12159-ledger-convergence", "pr": "https://github.com/objectstack-ai/objectstack/pull/12419", "premise_still_valid": true, "summary": "Parts 1 and 2 implemented; Part 3 is a measured stop-and-report per the card's own instruction. Part 1 (#12359): sys_metadata_activation registration MOVED from the automation service's manifest to PlatformObjectsPlugin. The triage's open question is now measured — a double registration is NOT benign, registerObject throws 'already owned by package', so MOVE was the only available shape. Discharging the smooth-upgrade wall turned up something the ruling did not consider: a manifest is also a ROUTING decision (resolveDatasourceBinding step 4 routes by the owning package's defaultDatasource), so the naive fold would move the table from 'cloud' to the global default driver on any control-plane deployment — rows in one database, reads against another, every disabled artifact silently re-arming. Measured, then closed: the ledger rides its own manifest from the same plugin carrying the automation manifest's scope/namespace/defaultDatasource verbatim, so routing is byte-identical everywhere and the three siblings keep riding the project DB. Part 2 (#12350): the ADR-0126 §4 row contract now has ONE implementation, ObjectStoreMetadataActivationStore(engine, metadataType) in @objectstack/core (the package both consumers already depend on); each consumer keeps its name, one-arg constructor and docs, and fixes only the discriminator. No API break. Both pin suites stay byte-unchanged and green. Part 3: measurement shows customer-visible consequences BOTH ways, so no code was written and the decision note is posted for the maintainer.", "tests": "All at head 3ad9030e7 (also the final commit). Repo-wide `pnpm lint` (eslint . --no-inline-config) exit 0 — whole tree, no narrowing. Full suites: @objectstack/core 'Test Files 39 passed (39) · Tests 968 passed (968)'; @objectstack/platform-objects '31 passed (31) · 497 passed (497)'; @objectstack/service-automation '91 passed (91) · 1082 passed (1082)'; @objectstack/objectql '235 passed (235) · 4174 passed (4174)'; @objectstack/runtime activation dispatch+posture '2 passed (2) · 36 passed (36)'. typecheck green for objectql, platform-objects, dogfood, runtime (core and service-automation declare no typecheck script — verified by the echoed script names, not by a zero-match filter). NEW dogfood boots (packaged-activation-ledger-reach.dogfood.test.ts) 'Test Files 1 passed (1) · Tests 11 passed (11)', each describe carrying an anti-vacuity control on whether the automation service is really composed: actions-and-no-automation boot turns #12359's 503 SERVICE_UNAVAILABLE into 200, writes one install-level row (organization_id NULL), refuses dispatch 409 ACTION_DISABLED, and re-enable updates rather than deletes; the automation boot shows exactly one ledger owner, an unchanged datasource binding, both projections hydrating, and no cross-type row contamination. Boot-shape-exposure dogfood subset (meta routes, package-first authoring, registry gate wiring, route-ledger parity, derive-topology) '6 passed (6) · 45 passed (45)'. ABLATION (reverse verification that the two UNCHANGED pin suites really reach the consolidated implementation): removed the org-row skip from packages/core/src/utils/metadata-activation-store.ts via a python anchored replace, confirmed on disk both ways (anchor 1->0, marker 0->1, git diff --stat non-empty), rebuilt @objectstack/core, and confirmed the marker reached dist with `node scripts/ablation-dist-preflight.mjs @objectstack/core ... ` -> 'marker present in 2 built files ... the ablation is live in the artifact the suite consumes'. Both pin suites then went RED on exactly their org-skip assertion: flow 'Tests 1 failed | 28 passed (29) — SKIPS rows carrying an organization_id'; action 'Tests 1 failed | 17 passed (18) — SKIPS a row carrying an organization_id'. Restore leg run and verified with --absent: 'marker absent from all 12 built files'; anchor back in source = 1. A FIRST ablation attempt is declared and NOT quoted: its marker string contained '/', which broke the perl replacement so the marker-confirmation leg failed (the anchor-removal half held and both suites did redden, but that run's colour is not used. The clean re-run above is). GATES: derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`, RE-derived after the diff grew (the scripts/ ledger edit pulled in six more families), and the whole union re-run on the final head 3ad9030e7 — 30 families, all exit 0, plus the 4-family spec-liveness set. Verdict lines quoted from the gates themselves: 'check-engine-double-contract: OK — 416 pinned, 133 in the DEBT ledger, 2 exempt.'; 'where-matcher conformance holds: 303 matcher(s) discovered, 303 answer the combinator battery correctly or refuse it loudly (190 refuse). 0 silently-wrong and 0 unjudged ... none new.'; 'OK: 18 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.'; 'check-nul-bytes: OK (scanned 6869 text file(s) ... no raw ASCII control bytes).'; 'query-options-erasure ratchet holds: 67 unswept non-test site(s) ... none new. baseline key set verified against fe3d74f: no files added.'; 'slot-lookup ratchet holds: 107 unswept site(s) ... none new.'; 'check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 417.8s ... none above its recorded number. surplus: none'; 'check-test-source-alias OK — 72 packages with tests scanned'; 'check-type-source-resolution OK — 93 tsc program(s)'; 'check-i18n-bundles: OK (9 package(s) — all bundles in sync)'; 'check-i18n-stale-fill: OK ... 0 stale-fill leaf/leaves'; 'durability-degradation log levels: 29 durability-critical catch seam(s), all loud'; 'check:plugin-teardown-shape: 63 Plugin implementation(s) ... SHRINK-ONLY, baseline fully burned down'; 'kernel hook pin pairing: 4 dispatched kernel:* hook(s)'; 'check:published-files — 69 publishable package(s)'; 'check-page-declaration-shape: OK — 34 page entries'; 'OK: all 104 declared cross-package glob(s)'; 'OK check:comment-mask-adoption — 23 private comment-stripper(s)'; spec-liveness 'all classified (1 closed, 2 open, 4 output, 9 scope)' + strictness-ledger + variant-docs all green. TWO gates went red first and both changed the work rather than being worked around: (1) check:cross-package-test-inputs refused the 'automation no longer registers the ledger' pin, which had been reading the automation plugin's source across the package boundary — it was moved into @objectstack/service-automation and upgraded from a grep over module text to a BEHAVIOURAL pin over a real init() against a recording manifest service, asserting the object list by equality; (2) check:engine-double-contract asked for the new core store suite's double to be recorded — `--write` added exactly one row to engine-double-contract.pinned.json, the GROW-ONLY coverage ledger, and the shrink-only DEBT baseline was not touched. check:engine-split-ratio refused loudly on the shallow clone rather than answering wrongly; after `git fetch --shallow-since=2026-05-21 origin` it exits 0 at 'ratio: 97.6%'.", "open_questions": [ { "question": "Part 3 (ADR-0126 §8 item 3): how does sys_permission_set.active relate to the generic ledger — projection or migration? Measured, both routes have customer-visible consequences, so this is not mine to choose. Full note with every number: issue comment 5419380856.", "options": [ "A. Decline the convergence and record why — amend §8 item 3 to say the row-state door stays where #4669 put it. Ground: the ledger is for PACKAGED artifacts at INSTALL scope and sys_permission_set.active is neither. Measured on a real boot: 8 of 17 rows carry no package_id (the ledger's package_id is required:true), and every clone the #11513 lock names as its own remedy adds another (POST /data/sys_permission_set -> 201 with managed_by:'admin', package_id:null; PATCH {active:false} -> 200). Row identity also mismatches: sys_permission_set is unique:'organization' on name, while the ledger's organization_id is reserved, unwritten (§5) and skipped on read — collapsing per-org sets onto one install-level row is the #10243 leak the ledger exists to retire, from the other direction.", "B. Re-scope to the whole ADR-0049 family — one card covering permission / position / capability together. sys_position.active (16 rows, 0 packaged) and sys_capability.active (10 rows, 2 packaged) are the SAME switch through the SAME isRowActive predicate, so any answer here has to hold for all three; the card scopes it to one member.", "C. Migration anyway — accept a data migration for unpackaged rows, a per-org dimension the ledger has not chartered (§5), and changes to two Setup list views, a declared index, the two lifecycle actions, the clone action and the explain panel's `deactivated` verdict. Not recommended on any of the four axes.", "D. Projection — its read-only half changes nothing a customer sees AND delivers no convergence (it adds a second way to ask one question, the exact shape #12350 in this same PR exists to remove); its write-through half IS customer-visible, becoming a second write path to the column that bypasses the #4669 row-state carve-out ordering and the #11513 packaged-set lock pre-pass, which the card forbids." ], "recommendation": "A, with B as the follow-up card. Four axes: (real business need) no measured pull — zero inactive permission-set rows on a stock boot, and the active switch that IS exercised is the #11513 clone/deactivate loop, which works today and is unrelated to packaged-artifact activation; (long-term soundness) the ledger's declared row identity and this column's are different contracts — A bends the ledger's, D duplicates a question, neither is contract-first; (AI-safety) a second write door for the same bit is exactly where an AI-authored admin app would flip a set without passing the packaged-set lock, and today there is one door; (startup scope) an unpulled convergence over a landed working surface, scoped to one member of a three-member family." }, { "question": "Part 1 side-finding the ruling did not consider: the activation ledger's DATASOURCE is carried by whichever manifest registers it. This PR preserves today's routing so the move is a proven no-op, but it does not decide where the ledger should actually live.", "options": [ "A. Leave as shipped — the ledger keeps riding defaultDatasource:'cloud' (the control-plane database) exactly as it did under the automation service's manifest. No data moves; the status quo is preserved rather than endorsed.", "B. Move it to the project database alongside sys_migration / sys_migration_journal / sys_secret — arguably where an INSTALL-level, per-deployment configuration ledger belongs, but it is a data migration for every deployment that has rows there, not a manifest edit." ], "recommendation": "A for now, as shipped: the smooth-upgrade wall on this card required preserving existing data placement, and B is a separate card that must move the rows, not just the declaration. Worth filing if the maintainer wants the ledger on the project plane — I did not file it, because the answer depends on how the control-plane proxy treats an install-level row with a NULL organization_id, which I could not measure from this repo (the `cloud` datasource is defined in the sibling repo and no composition here registers one)." } ], "out_of_scope_findings": [] }
Generated by Claude Code
os-support-ai commented
on Aug 26, 2026 CollaboratorAuthorMore actionsMaintainer ruling on Part 3 recorded (2026-08-26, live PM chat, session
session_01KWRU3s15AJz7PGW7a7wdCh). Presented with the measured decision note and four-axis analysis of the three routes; the maintainer ruled, verbatim: 「同意」 — to the recommendation:Option A — the convergence is declined, and the declination is the decision.
sys_permission_set.activestays the authoritative switch for permission sets; the genericsys_metadata_activationledger governs packaged behavioral artifacts (flows, actions, and future Regime C consumers) only. Grounds, from the measurements on record: the ledger's requiredpackage_idcannot hold the growing unpackaged half of the permission-set population (8/17 stock, +1 per sanctioned clone); per-organization uniqueness cannot collapse onto install-level rows without reintroducing cross-org interference; the column carries a bound ecosystem (index, Setup views, lifecycle actions, clone, explain verdict); read-only projection delivers no convergence and write-through projection bypasses the #4669 ordering and the #11513 packaged-set lock.Family scope: this ruling applies to the whole ADR-0049 row-switch family —
sys_permission_set.active,sys_position.active,sys_capability.active(sameisRowActivepredicate). Each keeps its own column; none converges onto the ledger.Consequences: Part 3 of this card is concluded by ruling (no code); PR #12419 (Parts 1+2) proceeds and its
Closes #12159stands. ADR-0126 §8 item 3 can be annotated settled-declined in a dedicated docs-only PR if the maintainer wants the ADR text updated — the ruling of record lives here either way.
Generated by Claude Code
os-support-ai commented
on Aug 26, 2026 CollaboratorAuthorMore actionsMaintainer ruling recorded — Part 3 (permission-set convergence): option A, decline-and-record (2026-08-26, live PM chat, session
session_01KWRU3s15AJz7PGW7a7wdCh). The Part 3 decision note above (comment 5419380856) was presented to the maintainer with the measurements and a four-axis comparison of the three routes; the maintainer ruled, verbatim: 「同意」 — to the recommendation:- The convergence is declined, and the declining is the decision.
sys_permission_set.activestays the authoritative switch for permission sets;sys_metadata_activationremains scoped to packaged behavioral artifacts (flows, actions, and future Regime C consumers). Grounds, from the measurements: migration is unsound (8/17 rows carry nopackage_idand every Studio save of a package-declared permission set forks it into a silent, undiscoverable overlay #11513 clone adds another, against the ledger'srequired: true; per-org uniqueness cannot collapse onto install-level rows without reintroducing the Decide whether POST /api/v1/automation/:name/toggle belongs in the manage_metadata write set — it mutates flow enablement with no authoring capability #10243 class of leak; index, two Setup views, lifecycle actions, clone action and the explain panel all bind to the column), and projection either delivers nothing (read-only) or bypasses the permission-set backfill (ADR-0094 D4) 现在 100% 失败:行里的active存储列喂进了 #4001 之后严格化的 permission spec #4669 ordering and the Studio save of a package-declared permission set forks it into a silent, undiscoverable overlay #11513 lock pre-pass (write-through). - Family-wide effect: the same ruling covers
sys_position.activeandsys_capability.active— the same ADR-0049 switch judged by the sameisRowActivepredicate. All three keep their columns; no convergence card is to be filed for them. - Consequence for this card: Part 3 is concluded by ruling, no code. PR Ledger convergence: registration home + one store implementation (ADR-0126 §4/§8, maintainer-ruled) #12419 (Parts 1+2) proceeds and its
Closes #12159stands.
Also recorded as informational, not ruled: the ledger's datasource home (PR #12419 preserves today's routing verbatim, deliberately); revisit only if real pull appears — moving it later means moving the rows too.
Generated by Claude Code
- The convergence is declined, and the declining is the decision.
- added a commit that references this issue
on Oct 9, 2026
Part of #12150 (Epic: ADR-0126 implementation, v17 line — 17.x minor, maintainer-ruled).
Maintainer ruling, 2026-08-26, live PM chat, verbatim: 「同意」 — to the three-part package presented with four-axis analysis (full ruling record: #12359 comment 5419050253): #12359 option B, #12350, and this card's original L8 scope are one card, one PR, one verification pass. This card is that card; its PR closes #12159, #12350 and #12359.
Dispatch: coordinated under the #12150 program — ⛔ not from the general queue (maintainer anti-preemption instruction, 2026-08-25, PM session
session_01KWRU3s15AJz7PGW7a7wdCh). Contract: ADR-0126 §4 walls + §8 items 2–3, ADR-0029 D7 (object ownership), ADR-0068 D2 (write authority unchanged).Part 1 — registration home (#12359, ruled: option B)
sys_metadata_activationregistration moves from the automation service's manifest (registerRunObject, besidesys_automation_run/sys_flow_dispatch) toPlatformObjectsPlugin— where the object is declared, besideSysMigration/SysMigrationJournal/SysSecret. Move, not add: single owner, no double registration. Consequence: every composition carrying platform-objects has the ledger, so packaged-action disable works without the automation service, and every future Regime C consumer inherits it.sys_metadata_activationhas only one registrant #12359 measured 503 (actions-and-no-automation boot,bootStack(showcaseStack)+ flip →SERVICE_UNAVAILABLE) becomes this part's positive test: same boot, flip succeeds, disable consulted at dispatch.metadata:reloadedre-attach (both the flow and the action projections hydrate), no ownership-diagnostic regression (ADR-0029 D7).Part 2 — one store implementation (#12350)
One
metadata_type-parameterized store replaces the two copies of the §4 row contract (ObjectStoreFlowActivationStorein service-automation,ObjectStoreActionActivationStorein objectql). Home: a package BOTH may depend on —@objectstack/coreis the leading candidate (#12350's own analysis); ⛔ platform-objects as code home would invert the tiering for objectql (orthogonal to Part 1's registration move, which is composition-level). Both consumers move onto it; the row semantics stay byte-equivalent: install-level rows only, org-carrying rows skipped on read, driver0read as false, read-then-write, no engine-slicedelete.flow-activation-ledger.test.ts,action-activation.test.ts) stay green unchanged — they pin the contract, so they are the proof the consolidation lost nothing.Part 3 — permission-set convergence (original L8, ADR-0126 §8 item 3)
Decide and implement how
sys_permission_set.active(the #11513 row-state door,permission-set-projection.ts#4669 carve-out) relates to the generic ledger — projection (column stays, ledger reads through) vs migration (rows move, column retired). §4 walls inherited: no definitions in the ledger, no linkage columns.sys_permission_set.activevalues preserved whichever route.Acceptance
Closes #12159,Closes #12350,Closes #12359; three parts verifiable independently in the diff.packages/specsurface; smooth upgrade (the table already exists in live databases — moving its registrar must be a no-op for existing data).Refs: #12359 (ruling) · #12350 · ADR-0126 §4/§8 · ADR-0029 D7 · #11513/#4669 ·
permission-set-projection.ts:1128-1145