Repository navigation
fix(plugin-sharing): a minted share link carries its organization, never NULL (ADR-0131 D9, C8 stage 8b-0h) - #24461
Conversation
…0131 D9) Claude-Session: https://claude.ai/code/session_01DhtBNRSSLKsbimJ37KcfZy Co-authored-by: Claude <noreply@anthropic.com>
… a real engine Claude-Session: https://claude.ai/code/session_01DhtBNRSSLKsbimJ37KcfZy Co-authored-by: Claude <noreply@anthropic.com>
…ization object is registered Claude-Session: https://claude.ai/code/session_01DhtBNRSSLKsbimJ37KcfZy Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhtBNRSSLKsbimJ37KcfZy Co-authored-by: Claude <noreply@anthropic.com>
…us-decidable, typed spellings Claude-Session: https://claude.ai/code/session_01DhtBNRSSLKsbimJ37KcfZy Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 7 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 — 9 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 9affd116663b8c86048f6fd44155c8b413f72c7e && git checkout 9affd116663b8c86048f6fd44155c8b413f72c7e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 725a5b1311ecc051edafcd8122ad47dfe1ccfbf8 a117f81c431314b0dbba46c865b7393b876f6a38 && git checkout -B drift-repro 725a5b1311ecc051edafcd8122ad47dfe1ccfbf8 && git merge --no-ff a117f81c431314b0dbba46c865b7393b876f6a38
node scripts/docs-audit/affected-docs.mjs --json 725a5b1311ecc051edafcd8122ad47dfe1ccfbf8
|
Part of #15212
Clause-②: no
C8 stage 8b-0h (claim 6259036738, pointer 6258557956, plan amendment 11 6259028550).
ShareLinkService.createLinkstampssys_share_link.organization_id, so a minted link is never a NULL row. #15212 remains open; stage 8b and the rest of C8 are not in this PR.What changed
packages/plugins/plugin-sharing/src/share-link-service.ts: a new privateresolveMintOrganization. It runs after the authority and eligibility checks and before the insert. In order:fields: ['id', TENANT_FIELD], with the tenant field fromresolveTenantFieldName. It takes the system context so that field-level masking cannot hide the column. The record's organization wins over the session's.tenantId), when the record names none: an object with no tenant column, or a row with no organization.resolveSystemWriteOrganization, from@objectstack/corethrough@objectstack/objectql's re-export of the same binding). Undersinglewith exactly one organization, it derives the Default Organization. Otherwise it refuses with the engine's ownSystemWriteOrganizationRequiredError(ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED, status 500). That covers a walled posture, and asingleinstall with none or several organizations. Nothing is written.The stamp rides both the row and the write context (
{ ...SYSTEM_CTX, tenantId }), the shapeSharingService.grantuses forsys_record_share. The mint response is still the contract'sShareLinkshape, which declares no organization column. Nothing is stamped whensys_share_linkhas no tenant column, or when the composition registers nosys_organization; the probe answers[]without a read, like the engine's own probe.The posture is the requested one (
resolveTenancyPosture()).resolveAuthzContext's platform-anchor read and thetenancyservice'sdefaultOrgId()read the same source in the same fail-stricter direction: a deployment that asked for a wall stays walled here even when it runs degraded.Why the service decides (not the engine)
sys_share_linkisunclassifiedin the #13491 ledger, soEngine.resolveSystemInsertOrganizationreturns early for it (isPlatformObjectOutOfTenantAuditScope,engine.tsabout:7183), and today the NULL insert goes through. The service raises the engine's own refusal envelope, so a walled mint gets the same answer before and after 8b retires that exemption. This PR does not touch the engine or the ledger.Measured (dispatch assumptions)
538f923cb,git grepforinsert|create|upsert('sys_share_link'finds one site:share-link-service.ts:665.revokeLinkand the usage stamp only update existing rows.fields: ['id']unless an eligibility predicate is declared. It runs under the caller's context, where field-level masking applies. So the stage adds the narrowest system read,['id', TENANT_FIELD]. A failing read propagates, so the mint fails and nothing is written. The stage never stamps the session's organization in place of an unknown record organization.group. Layer 0 is a membership union there, so a member active in A can mint on a visible record in B. Theisolatedwall is equality, so both are the same. The owner and Modify-All alternative is withheld under a wall. A system caller reads under the system context. In every case the record's organization is stamped (pin 1).createLinkcallers are the two HTTP doors (share-link-routes.ts:362,runtime/src/domains/share-links.ts:433). Both require auserId. Under a wall, an organization-less API key is refused at admission (share-link-tenancy-posture-admission.test.ts). An organization-less session fails Layer 0 on any tenant-columned record, so the visibility check refuses first. No in-repo object pairspublicSharingwithtenancy.enabled: false; the onlypublicSharingobject is showcase'sclient-brief, which is tenant-columned. The refusal is reachable only by an external system caller that threads notenantId, or by a third-party organization-less object. So the claim'sClause-②: nois kept as written.Tests
share-link-organization-stamp.test.ts: a realObjectQLandSqlDriver(better-sqlite3:memory:), the realSysShareLinkobject, and rows read back off the driver. The posture is stubbed throughOS_TENANCY_POSTURE.sys_share_linkcarries the tenant column.single: the Default Organization, and the response has noorganization_id.codeandstatus), and zero rows.scripts/ablation-replace.mjs, on commit36706e5e. Every anchor hit 1 → 0, and every restore showed blob == HEAD with an emptygit diff HEAD. The test imports./share-link-service.jsfromsrc, so no build or dist step is involved.derivedreturn: only pin 3 goes red.return undefined: only pin 4 goes red.pnpm --filter @objectstack/plugin-sharing test: 49 files, 1144 tests pass.typecheck: exit 0.pnpm --filter @objectstack/runtime exec vitest run src/domains/share-links: 6 files, 202 tests pass. These suites constructShareLinkService.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 67 commands, plus the 53 artifact-roster families printed on STDERR (118 unique). All of them were run, and the readings are in the dev report on #15212. The first run found two real problems, both fixed ina117f81c:check-tenant-audit-census: a conditional insert context counted as "elevation undecidable". It is now{ ...SYSTEM_CTX, tenantId }.check:query-options-erasure: 5 → 7 because of twoas anycasts on find options. Both casts are dropped.The rest exit non-zero only because they cannot measure locally. Four need PR context (
closing-target-claim,partof-closing-keyword,single-claim-paths,governed-queue-guard). One needs an unshallowed clone (check-plugin-teardown-shape --self-test). Three need a full workspace build (check:i18n,check:dual-build-cjs-loads,check:published-readme-exports). All of these are NOT MEASURED locally and are left to CI.Acceptance notes
sys_share_linkrow inplatform-object-tenancy.tsis stillunclassified. With the writer now stamping, it is ready for 8b, which owns the engine-side change; that is not done here.sys_share_linkrow inorganization-ownership-inventory.ts(fate: 'attribution', subject anchorobject_name/record_id) already matches this stamp. No change is needed.sharing-plugin.tsis not touched. The stage needs no new wiring, because the posture is the requested one and the Default Organization comes from the same D9 probe the engine uses.Generated by Claude Code