Found while fixing #4774 item 2 (the showcase's showcase.export_data warning). The showcase side is fixed there by authoring the ADR-0086 D3 packageId fallback; this issue is the platform half, which #4774's PM direction asked to be split out rather than worked around in the example.
Two defects in one chain. Together they turn "an app declares a capability" into "a permission set grants a capability that exists nowhere", announced by a single warn.
Part 1 — a REFUSED declaration is still reported as declared
SecurityPlugin seeds sys_capability in two passes and deliberately tells the second pass to skip anything the first already declared:
packages/plugins/plugin-security/src/security-plugin.ts (~2002):
const capOutcome = await bootstrapDeclaredCapabilities(ql, this.metadata, { logger: ctx.logger });
declaredCapabilityNames = capOutcome.declaredNames;
// …
await bootstrapSystemCapabilities(ql, this.bootstrapPermissionSets, {
logger: ctx.logger,
declaredCapabilityNames, // ← "skip these, a package owns them"
});
But declaredNames is filled before the upsert decides anything —
packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts (~172):
for (const cap of caps) {
if (!cap?.name) continue;
out.declaredNames.push(cap.name); // ← unconditional
const packageId = cap._packageId ?? cap.packageId ?? undefined;
await upsertPackageCapability(ql, cap, packageId, out, options.logger);
}
upsertPackageCapability has three refusal paths that return without writing a row: a curated platform name (skippedPlatform), no owning package, and a name owned by another package (skippedForeign). Each of those names is nevertheless in declaredNames, so bootstrapSystemCapabilities skips deriving it too.
The back-compat derivation is the only thing that used to make a referenced-but-undeclared capability resolve. So the net effect of adding an explicit declaration that gets refused is strictly worse than not declaring it at all: the placeholder that would have existed is now suppressed, and nothing replaces it. The capability exists in no row, and every systemPermissions grant naming it is inert.
The comment above the call states the intended contract exactly — "SKIPPING any name a package already declared (so the placeholder never clobbers the authored capability)" — and a refused declaration is not an authored capability there is anything to protect.
Suggested fix: report only the names that actually landed (seeded / updated / claimed). A refused declaration should fall through to the derived placeholder, not into a hole. That also makes the outcome counters and declaredNames agree, which they currently do not.
Part 2 — stack.capabilities never reaches the registry, so _packageId is never stamped
bootstrapDeclaredCapabilities resolves provenance as cap._packageId ?? cap.packageId. The _packageId half can never fire for an app-declared capability:
AppPlugin surfaces stack security metadata through metadata.registerInMemory(type, item.name, item) (packages/runtime/src/app-plugin.ts ~628) — and MetadataManager.registerInMemory only does this.registry.get(type).set(name, data). It stamps nothing.
- The stamping path is
SchemaRegistry.registerItem(type, item, 'name', packageId), which calls applyProtection(item, { packageId }). That is reached from ObjectQLEngine.use(), which walks a hardcoded metadataArrayKeys list (packages/objectql/src/engine.ts ~1735). That list carries 'roles', 'permissions', 'profiles', 'sharingRules', 'policies' — 'capabilities' is not in it.
So permission sets get stamped and materialize; capabilities are never registered through that seam and always arrive unstamped. readDeclared(ql, 'capability') returns [] and the metadata-service fallback yields the unstamped objects.
The consequence is that the author-declared packageId — documented as the fallback for when the registry stamp is absent — is in practice mandatory, with nothing saying so. Omitting it is accepted at authoring time (it is .optional()), and the failure appears as one boot warn plus an inert security grant.
Suggested fix: register capabilities through the same provenance-stamping seam as permissions. Failing that, CapabilityDeclarationSchema.packageId should stop describing itself as a fallback for a stamp that never happens.
Repro
rm -rf examples/app-showcase/.objectstack
pnpm --filter @objectstack/example-showcase exec objectstack dev --fresh -p 39774 --log-level warn
On main @ 6bc93dc, before #4774's showcase-side fix:
WARN [security] capability has no owning package — not materialized {"name":"showcase.export_data"} (×3)
and OpsPermissionSet.systemPermissions: ['setup.access', 'showcase.export_data'] grants a capability with no row anywhere.
Part 3 (smaller) — is one warn the right volume? (#4632)
Raised in #4774's PM direction and recorded here rather than guessed at. "This app grants a permission that will never exist" is a security declaration that reads as enforcement and enforces nothing — the ADR-0049 "declared ≠ enforced" shape. It is functional degradation, not durability, so #4632's rule points at warn rather than error. But it is currently a warn per capability, emitted from the seeder, which names the capability and not the permission set(s) that grant it, so the reader is not told the consequence.
The cheap improvement is probably not the level but the message: name the grantors, i.e. "showcase.export_data is granted by showcase_ops and will not exist". Note also that validateCapabilityReferences (packages/lint/src/validate-capability-references.ts) deliberately does not lint systemPermissions — "it is the declaration side, and a package legitimately introduces new capabilities there" — which is correct while the derivation back-door exists, and is exactly what Part 1 breaks.
Filed unassigned per Prime Directive #10.
Found while fixing #4774 item 2 (the showcase's
showcase.export_datawarning). The showcase side is fixed there by authoring the ADR-0086 D3packageIdfallback; this issue is the platform half, which #4774's PM direction asked to be split out rather than worked around in the example.Two defects in one chain. Together they turn "an app declares a capability" into "a permission set grants a capability that exists nowhere", announced by a single
warn.Part 1 — a REFUSED declaration is still reported as declared
SecurityPluginseedssys_capabilityin two passes and deliberately tells the second pass to skip anything the first already declared:packages/plugins/plugin-security/src/security-plugin.ts(~2002):But
declaredNamesis filled before the upsert decides anything —packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts(~172):upsertPackageCapabilityhas three refusal paths that return without writing a row: a curated platform name (skippedPlatform), no owning package, and a name owned by another package (skippedForeign). Each of those names is nevertheless indeclaredNames, sobootstrapSystemCapabilitiesskips deriving it too.The back-compat derivation is the only thing that used to make a referenced-but-undeclared capability resolve. So the net effect of adding an explicit declaration that gets refused is strictly worse than not declaring it at all: the placeholder that would have existed is now suppressed, and nothing replaces it. The capability exists in no row, and every
systemPermissionsgrant naming it is inert.The comment above the call states the intended contract exactly — "SKIPPING any name a package already declared (so the placeholder never clobbers the authored capability)" — and a refused declaration is not an authored capability there is anything to protect.
Suggested fix: report only the names that actually landed (seeded / updated / claimed). A refused declaration should fall through to the derived placeholder, not into a hole. That also makes the outcome counters and
declaredNamesagree, which they currently do not.Part 2 —
stack.capabilitiesnever reaches the registry, so_packageIdis never stampedbootstrapDeclaredCapabilitiesresolves provenance ascap._packageId ?? cap.packageId. The_packageIdhalf can never fire for an app-declared capability:AppPluginsurfaces stack security metadata throughmetadata.registerInMemory(type, item.name, item)(packages/runtime/src/app-plugin.ts~628) — andMetadataManager.registerInMemoryonly doesthis.registry.get(type).set(name, data). It stamps nothing.SchemaRegistry.registerItem(type, item, 'name', packageId), which callsapplyProtection(item, { packageId }). That is reached fromObjectQLEngine.use(), which walks a hardcodedmetadataArrayKeyslist (packages/objectql/src/engine.ts~1735). That list carries'roles', 'permissions', 'profiles', 'sharingRules', 'policies'—'capabilities'is not in it.So permission sets get stamped and materialize; capabilities are never registered through that seam and always arrive unstamped.
readDeclared(ql, 'capability')returns[]and the metadata-service fallback yields the unstamped objects.The consequence is that the author-declared
packageId— documented as the fallback for when the registry stamp is absent — is in practice mandatory, with nothing saying so. Omitting it is accepted at authoring time (it is.optional()), and the failure appears as one bootwarnplus an inert security grant.Suggested fix: register
capabilitiesthrough the same provenance-stamping seam aspermissions. Failing that,CapabilityDeclarationSchema.packageIdshould stop describing itself as a fallback for a stamp that never happens.Repro
rm -rf examples/app-showcase/.objectstack pnpm --filter @objectstack/example-showcase exec objectstack dev --fresh -p 39774 --log-level warnOn
main@ 6bc93dc, before #4774's showcase-side fix:and
OpsPermissionSet.systemPermissions: ['setup.access', 'showcase.export_data']grants a capability with no row anywhere.Part 3 (smaller) — is one
warnthe right volume? (#4632)Raised in #4774's PM direction and recorded here rather than guessed at. "This app grants a permission that will never exist" is a security declaration that reads as enforcement and enforces nothing — the ADR-0049 "declared ≠ enforced" shape. It is functional degradation, not durability, so #4632's rule points at
warnrather thanerror. But it is currently awarnper capability, emitted from the seeder, which names the capability and not the permission set(s) that grant it, so the reader is not told the consequence.The cheap improvement is probably not the level but the message: name the grantors, i.e. "
showcase.export_datais granted byshowcase_opsand will not exist". Note also thatvalidateCapabilityReferences(packages/lint/src/validate-capability-references.ts) deliberately does not lintsystemPermissions— "it is the declaration side, and a package legitimately introduces new capabilities there" — which is correct while the derivation back-door exists, and is exactly what Part 1 breaks.Filed unassigned per Prime Directive #10.