Repository navigation
feat(spec): ISecurityService declares discardPermissionSetOverlay and contributeOwnershipFloorAlternates as optional, feature-detected members - #21781
Conversation
…pFloorAlternates on ISecurityService Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…curity-service-members
…inor changeset Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 354c6a2b4f0561e352a2e92d686d8e589248c5fb && git checkout 354c6a2b4f0561e352a2e92d686d8e589248c5fb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8256a4b272f92908376d39518d1eb20914be483d f2f466c4a97d531475f8db1a10433f94fbaff788 && git checkout -B drift-repro 8256a4b272f92908376d39518d1eb20914be483d && git merge --no-ff f2f466c4a97d531475f8db1a10433f94fbaff788
node scripts/docs-audit/affected-docs.mjs --json 8256a4b272f92908376d39518d1eb20914be483d
|
Fixes #21756
Clause-②: yes (widening)
ISecurityServicenow declares the two members that the registeredsecurityservice already served without a declaration:discardPermissionSetOverlayandcontributeOwnershipFloorAlternates. Both are optional, documented as feature-detected, and pinned by a contract-test row each. A new test-only enumeration pin inplugin-securityturns red, by name, when the registered service serves a member that is neither declared on the contract nor ledgered with a reason. This follows the direction triage set (5981803299) and the claim5982111525. No runtime source changes, and no behaviour changes.Measured first: what is served vs what is declared
Read at base
a6a7547074.registeredSecurityServiceinpackages/plugins/plugin-security/src/security-plugin.ts(:1881typed literal +:2106Object.assignextension) serves 21 members. I measured them by enumerating the object the real plugin registers, not by reading the source:canExport,canReadObject,checkAuthoredRowWrite,confirmAudienceBindingSuggestion,contributeOwnershipFloorAlternates,describeDelegableScope,describeDelegationNarrowing,discardPermissionSetOverlay,dismissAudienceBindingSuggestion,explain,getEffectiveObjectPermissions,getMetadataReadableFields,getQueryableFields,getReadFilter,getReadableFields,getWritableFields,hasWriteBypass,listAudienceBindingSuggestions,resolvePermissionSetNames,resolvePermissionSetsForContext,resolveWriteScope.The two callers and what the members really refuse
discardPermissionSetOverlay(callerContext, id): called bypackages/rest/src/rest-server.ts(POST …/security/permission-sets/:id/discard-overlay, about:12699). The route feature-detects it and answers501 NOT_IMPLEMENTEDwhen it is absent. The implementation (permission-set-overlay-discard.ts) refuses withPERMISSION_DENIED403 (the caller is not a tenant-level admin, or no installed package declares the set),NOT_FOUND404 (unknown row) andINVALID_STATE409 (no active overlay). The last two are theERROR_CODE_LEDGER['@objectstack/plugin-security']rows. A refused re-projection write still resolves (healedObjectGrantCountthen equals the pre-discard count). The docblock says so.contributeOwnershipFloorAlternates(plugin, alternates): called at boot bypackages/services/service-storage/src/attachment-delete-floor-alternate.ts, through a local seam interface and feature detection. The implementation (ownership-floor-alternates.ts) throws a plainErrorwith no registered code when:pluginis empty, the list is not an array, an alternate names no object or names'*', its operation is not exactlyupdateordelete, itsusingis missing, or the policy does not parse asRowLevelSecurityPolicySchema.What changed
packages/spec/src/contracts/security-service.ts: two optional members, documented in thegetMetadataReadableFieldspattern (what each does, what it refuses with which codes, that callers feature-detect, that the unguarded call does not compile). Two parameter and result types are plugin-internal (PermissionSetOverlayDiscardResultandOwnershipFloorAlternateinplugin-security), so the spec declares the minimal contract shape of each under the same name, and the docblocks say so. They are the only new public exports, both type-only (api-surface/contracts.json+2,export-origins/contracts.json+2, regenerated bycheck:generated --fix, not by hand).packages/spec/src/contracts/security-service.test.ts: one contract-test row per member, appended after the existing rows. Each shows that absence is typed (the unguarded call is a@ts-expect-error), how the caller handles the absent branch, and that the present member is called as declared. The second row also shows the type rejectsoperation: 'all'. No existing title is edited.packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts(new, test-only): the enumeration pin..changeset/21756-security-service-declared-members.md:@objectstack/specminor,Clause-②: yes (widening).The pin, and why the declared list is test-local
The pin boots the real
SecurityPlugin(init+start) and takes the object it passes toregisterService('security', …). It walks every own key along the prototype chain, so a class-backed service would not pass over zero members. It fails, by name, on any member that is in neitherDECLARED_MEMBERSnorSERVED_NOT_DECLARED. Its non-vacuity control requires every required member to be among the enumerated ones.It needs a runtime list of declared members. I chose a test-local
DECLARED_MEMBERSmap held to the interface bysatisfies { readonly [K in keyof ISecurityService]-?: 'required' | 'optional' }, computed per member. If the interface gains a member and the list does not, the list stops compiling. A name the interface lacks, or a wrong required/optional tag, also stops it compiling. The compile half runs in this package'stypecheck(tsconfig.test.jsoncompiles every test here, at zero debt). No new spec export was needed. A runtime list exported frompackages/specwould have grown the published surface to serve one test, and would still need a clause like this one to keep it equal to the interface. The pin's boot fake has no engine write or read verb (objectqlcarries onlyregisterMiddlewareandgetSchema), so it is not a double thecheck:engine-double-contractfamily scans. A third case is compile-only: it types a witness of each extension member's contract signature, delegating to the implementation function the registered member delegates to. If the contract and the implementation disagree, that case stops compiling.Proof the pin can fail (predicted first, run from committed state)
Run from commit
ff69d4c4eb. Mutations went throughnode scripts/ablation-replace.mjs(anchor must hit, blob must move, restore proven by blob == HEAD and an emptygit diff HEAD). The pin imports./security-plugin.jsby relative path, so nodist/sits between the mutation and the run.zzScratchServedMember; tests 2 and 3 green. I plantedzzScratchServedMember: () => undefinedin theObject.assignextension ofsecurity-plugin.ts(blobbf796ff10c8d->cd61be11613c). Observed, as predicted:AssertionError: served by the registered security service but neither declared on ISecurityService … expected [ 'zzScratchServedMember' ] to deeply equal [],Tests 1 failed | 2 passed (3). Restored to blobbf796ff10c8d== HEAD,git diff HEADempty. (My first attempt used an anchor that the replacement still contained. The tool refused it before running anything, so it measured nothing. The second attempt is the measurement.)tsc -p tsconfig.test.jsonred at thesatisfiesclause, in this file only. I droppedcontributeOwnershipFloorAlternatesfromDECLARED_MEMBERS. Observed:registered-security-service-members.pin.test.ts(77,12): error TS1360: … does not satisfy the expected type 'DeclaredOptionality', the only error in the program. Restored to blob == HEAD. This also proves the test program read the rebuilt spec.d.ts: against a stale one without the new member, the unmutated list would be the red one (an excess property).Verification, on the final tree
All runs below are at
f2f466c4a9(the branch merged withorigin/mainebfe658c72, artifacts regenerated, changeset committed).pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 src/contracts/security-service.test.ts:Tests 23 passed (23)(21 before + 2).pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2(whole package):Test Files 615 passed (615),Tests 18360 passed | 1 todo.pnpm --filter @objectstack/spec typecheck: exit 0. The test layer compiles, andsecurity-service.test.tshas notest-typecheck-debt.jsonentry, so its@ts-expect-errorlines are live checks.pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2(whole package):Test Files 166 passed (166),Tests 3582 passed | 45 skipped.pnpm --filter @objectstack/plugin-security typecheck: exit 0 (0 file(s) / 0 error(s)in the test-layer ledger).pnpm --filter @objectstack/spec check:generated: exit 1 before the fix (exactlyapi-surface/andexport-origins/stale, +2 interfaces each). After--fixre-checked them:✓ check:api-surface,✓ check:export-origins.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 91 commands from the merge-base change set (6 paths). I ran all 91 with their exit codes recorded before any pipe: 89 exited 0.pnpm check:i18nandpnpm check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET, nodist/). Both were re-run after building their prerequisites (the closurecheck:i18nnames, then a workspacepnpm build, all turbo cache hits) and exited 0:check-i18n-bundles: OK (9 package(s) — all bundles in sync …)and✓ check:dual-build-cjs-loads — 106 published require entry point(s) across 66 package(s) load.dispatch-gates.mjs --ranover the recorded codes:91 derived famil(ies) accounted for — 91 run, 0 NOT-MEASURED. The six artifact-roster gates whose roster sits under a touched directory (check-changeset-fixed,check:meta-url-spelling,check:spec-changes,check:authz-resolver,check:error-code-casing,check:filter-alias-parity) were also run, and all exited 0.pnpm exec eslint --no-inline-config --format jsonover the three changed.tsfiles reportsfiles 3 errors 0 warnings 0. All three are in the configured population (eslint --print-configresolves each). This repo'seslint.config.mjsenables no type-aware linting (noparserOptions.project, noprojectService), so the diff cannot move the verdict on any untouched file. The fullpnpm lintis CI's.Consumers: the public-surface change is two optional interface members plus two type-only exports. The two callers do not type their access against
ISecurityService:rest-server.tsholds the service asany(its provider returns a Promise ofany), andservice-storageuses its own local seam interface. So their compiled verdicts cannot move, and they are not re-run here. Every other package that referencesISecurityServicereads it as aPartialofISecurityServiceor calls only pre-existing members (git grepoverpackages/**). The full consumer sweep is left to CI's workspace type-check lane.Overlap
#21763 (#20749 stage 13) rewrites tracker ids in test titles of
security-service.test.tsat lines 258, 287, 309, 471, 494 and 518. When this PR opened it was still in the merge queue, not onmain. This PR edits no existing title. It adds one import specifier (line 8) and two rows after the last existing row (after line 560), so no added line is next to a title #21763 changes. Neither new title carries a tracker id. A local trial merge of this branch with #21763's head (git merge-tree --write-tree; this file is not routed to the regen merge driver, so the text merge is the same one GitHub runs) is clean. Whichever lands later mergesorigin/main(no rebase).Acceptance notes
Noted, not filed. These are observations, not defects or contract violations. Each is out of this card's scope: the dispatch forbids editing
security-plugin.ts,rest-server.tsandservice-storagesource.security-plugin.tsaround:2100–:2122(the comments above theObject.assign), the header ofownership-floor-alternates.ts("an EXTENSION of the published contract"), and the header ofattachment-delete-floor-alternate.ts. Carrier: the next PR that edits those files.security-plugin.ts:2137prints a hand-written member list. It names the two extension members, but omitshasWriteBypass,resolveWriteScope,describeDelegationNarrowing,getEffectiveObjectPermissionsanddescribeDelegableScope. Log text only. Carrier: the next editor ofsecurity-plugin.ts. Holder: none.Object.assignextension into the typed literal, where the compiler holds their signatures directly. That would make the pin's compile witness redundant for them. It is aplugin-securitysource change, so it is not done here.Generated by Claude Code