From 0245ee74216b2dd040375e4018bd40ef7dc3b861 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 10:53:26 +0000 Subject: [PATCH] feat(objects): adopt the declared referenceVia pointer pair on four system objects (#11386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #11339 landed the polymorphic pointer-pair carrier (ADR-0052 §5 ActivityPointer) and adopted it on `sys_activity`. This card adopts it on the remaining system objects carrying the same `(object half, id half)` idiom — measured per object, not as a sweep: - sys_audit_log.record_id via object_name — declared - sys_approval_request.record_id via object_name — declared - sys_record_share.record_id via object_name — declared - sys_share_link.record_id via object_name — declared - sys_automation_run.trigger_record_id — deliberately NOT declared Each adoption re-verified that object's own pair semantics (who writes it, what reads it back) and its seed corpus before declaring, and each seed test asserts the query that makes the pair load-bearing on that object rather than a generic "the id resolved": the {object_name, record_id} index for the audit ledger, the pending-request lock for approvals, the grant lookup for record shares, and the fail-closed recordStillExists gate for share links. The fifth object records a deliberate "stays undeclared" verdict at the declaration site, with its reasons and what would have to change to flip it: a paused sys_automation_run row is a live continuation the engine rehydrates on boot, terminal rows are telemetry under a 30-day sweep, and the object has no natural key to address rows by — so declaring would advertise run rows as authorable seed content rather than make a real corpus resolvable. Measured while writing the tests, and recorded in them: unlike sys_activity, none of the four heals an out-of-order pointer in pass 2, because an engine-owned row has no natural key and pass 2 back-fills by externalId. The load fails loudly (the ruled family direction) rather than storing the literal; a positive control isolates the cause to keylessness rather than to the pointer pair. No new engine doubles, so no new rows in scripts/engine-double-contract.pinned.json. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --- .../pointer-pair-adoption-five-objects.md | 11 + .../src/seed-loader-pointer-pair.test.ts | 440 ++++++++++++++++++ .../sys-approval-request-pointer-pair.test.ts | 51 ++ .../src/sys-approval-request.object.ts | 18 + .../src/objects/sys-audit-log.object.ts | 21 + .../src/sys-audit-log-pointer-pair.test.ts | 54 +++ .../src/objects/sys-record-share.object.ts | 18 + .../src/objects/sys-share-link.object.ts | 17 + .../src/sharing-pointer-pairs.test.ts | 80 ++++ ...utomation-run-pointer-pair-verdict.test.ts | 69 +++ .../src/sys-automation-run.object.ts | 37 ++ 11 files changed, 816 insertions(+) create mode 100644 .changeset/pointer-pair-adoption-five-objects.md create mode 100644 packages/plugins/plugin-approvals/src/sys-approval-request-pointer-pair.test.ts create mode 100644 packages/plugins/plugin-audit/src/sys-audit-log-pointer-pair.test.ts create mode 100644 packages/plugins/plugin-sharing/src/sharing-pointer-pairs.test.ts create mode 100644 packages/services/service-automation/src/sys-automation-run-pointer-pair-verdict.test.ts diff --git a/.changeset/pointer-pair-adoption-five-objects.md b/.changeset/pointer-pair-adoption-five-objects.md new file mode 100644 index 0000000000..c967c62cd8 --- /dev/null +++ b/.changeset/pointer-pair-adoption-five-objects.md @@ -0,0 +1,11 @@ +--- +"@objectstack/plugin-audit": minor +"@objectstack/plugin-approvals": minor +"@objectstack/plugin-sharing": minor +--- + +Four more system objects declare their polymorphic pointer pair (#11386, ADR-0052 §5, adopting the carrier #11339 landed): `sys_audit_log.record_id`, `sys_approval_request.record_id`, `sys_record_share.record_id` and `sys_share_link.record_id` now carry `referenceVia: 'object_name'`. A seed row addressing one of these by the target's natural key resolves against the object its sibling column names, per row — so a packaged app can ship audit history, pending approvals, record grants and share links that actually attach to the records they are about, and the queries that give each row its meaning (the `{object_name, record_id}` index, the pending-request lock, the sharing middleware's grant lookup, the share link's fail-closed record-existence gate) match on the target's real id. + +The accept/reject contract changes with it on those four objects, deliberately and in the already-ruled direction: an unresolvable pointer on a DECLARED pair is a loud, counted failure instead of the old silent verbatim store. On a grant table that is the sharper win — a share whose `record_id` stayed a natural key enforced nothing while displaying as a grant, and was then deleted by the orphan sweep for describing a record that does not exist. Internal-id-shaped values still pass through verbatim, so a demo row about an already-deleted record (an `action: 'delete'` audit row) stays authorable. Undeclared text columns are untouched. + +The fifth object surveyed, `sys_automation_run` (`trigger_object` / `trigger_record_id`), deliberately STAYS UNDECLARED. Its pair has the same shape but its rows are not content about a record: a `paused` row is a live continuation the engine rehydrates on boot, terminal rows are telemetry under a 30-day sweep, and the object has no natural key to address rows by. The verdict, its reasons, and what would have to change to flip it are recorded on the field itself and pinned by a test. diff --git a/packages/metadata-protocol/src/seed-loader-pointer-pair.test.ts b/packages/metadata-protocol/src/seed-loader-pointer-pair.test.ts index 6125a7b970..078e1a1827 100644 --- a/packages/metadata-protocol/src/seed-loader-pointer-pair.test.ts +++ b/packages/metadata-protocol/src/seed-loader-pointer-pair.test.ts @@ -412,3 +412,443 @@ describe('seed pointer-pair resolution (#11339 — referenceVia)', () => { expect(logger.error).not.toHaveBeenCalled(); }); }); + +/** + * Per-object adoption of the declared pointer pair — #11386. + * + * #11339 landed the carrier and adopted it on `sys_activity`. This card adopts + * it on the remaining system objects that carry the same `(object half, id + * half)` idiom, and the card's own discipline is that they are adopted + * MEASURED PER OBJECT, not as a sweep: five objects sharing a declaration + * SHAPE do not thereby share semantics, and a sweep would declare a pair on an + * object whose corpus contradicts it while every test stayed green. + * + * So each case below drives the loader against ONE adopted object's real field + * shape and asserts THAT object's own consuming query — the query that makes + * the pair load-bearing there — rather than a generic "the id resolved": + * + * - `sys_audit_log` → the `{object_name, record_id}` index / `record_views` + * view, i.e. "who touched THIS record" + * - `sys_approval_request` → the pending-request lookup that + * `lifecycle-hooks.ts` holds the record LOCK with + * - `sys_record_share` → the grant lookup the sharing middleware enforces on + * - `sys_share_link` → the fail-closed `recordStillExists` gate a token + * resolve runs + * + * The fifth surveyed object, `sys_automation_run`, is deliberately NOT adopted + * and therefore has no case here; the verdict and its reasons are recorded at + * the declaration site (`sys-automation-run.object.ts`, `trigger_record_id`). + * + * ## Why every case below seeds its TARGET FIRST + * + * `sys_activity` heals an out-of-order pointer in pass 2 (the + * order-independence case above). None of these four inherits that, and the + * reason was measured here rather than assumed — twice over: + * + * - MEASURED, and the load-bearing one: all four are engine-owned rows with + * no natural key, so an honest seed dataset for them declares no + * `externalId`. Pass 2 back-fills by looking the row up BY its externalId, + * so it resolves the target and then has nowhere to write it ("Deferred + * reference DROPPED … has an empty externalId"). Pinned by the keyless + * case and its positive control in the `sys_audit_log` block. + * - NOT MEASURED here, and stated as such: on `sys_approval_request`, + * `sys_record_share` and `sys_share_link` the id half is also + * `required: true`, and pass 1 defers a reference by DELETING the column + * from the row (`seed-loader.ts`: "REMOVE the field rather than writing + * null … NOT NULL columns turned this into a loud constraint error"). A + * real engine enforces `required` on seed writes, so the deferred insert + * would fail there too — but this file's engine double does not validate, + * so no case here may claim it either way. + * + * Both roads end at the ruled family direction (loud, never silently wrong) + * and at the same one-line author fix: order the target dataset first. What + * they are NOT is order-independence, so no case below claims it. + */ + +/** The four adopted objects, mirroring their real declarations field-for-field + * in the shape this loader reads (type + `referenceVia`). The mirror is kept + * honest by a declaration pin in each owning package, which asserts the REAL + * object still declares the pair this file assumes. */ +const ADOPTER_SCHEMAS: Record = { + crm_lead: { + name: 'crm_lead', + fields: { name: { type: 'text', required: true }, status: { type: 'text' } }, + }, + sys_audit_log: { + name: 'sys_audit_log', + fields: { + action: { type: 'select' }, + actor: { type: 'text' }, + object_name: { type: 'text' }, + record_id: { type: 'text', referenceVia: 'object_name' }, + }, + }, + sys_approval_request: { + name: 'sys_approval_request', + fields: { + process_name: { type: 'text', required: true }, + object_name: { type: 'text', required: true }, + record_id: { type: 'text', required: true, referenceVia: 'object_name' }, + status: { type: 'select' }, + }, + }, + sys_record_share: { + name: 'sys_record_share', + fields: { + object_name: { type: 'text', required: true }, + record_id: { type: 'text', required: true, referenceVia: 'object_name' }, + recipient_type: { type: 'select' }, + recipient_id: { type: 'text' }, + access_level: { type: 'select' }, + source: { type: 'select' }, + }, + }, + sys_share_link: { + name: 'sys_share_link', + fields: { + token: { type: 'text' }, + object_name: { type: 'text', required: true }, + record_id: { type: 'text', required: true, referenceVia: 'object_name' }, + permission: { type: 'select' }, + audience: { type: 'select' }, + }, + }, +}; + +/** A dataset for an object with no natural key of its own — every adopted + * object here is engine/append-only owned, so `mode: 'insert'` (no + * `externalId`) is how such rows would honestly be authored. */ +function insertSeed(object: string, records: Array>) { + return { object, mode: 'insert', env: ['prod', 'dev', 'test'], records }; +} + +describe('pointer-pair adoption per object (#11386)', () => { + describe('sys_audit_log — object_name / record_id', () => { + it('resolves the ledger pointer so "who touched THIS record" matches on the real id', async () => { + const { service, store } = newService(ADOPTER_SCHEMAS); + + const result = await service.load({ + seeds: [ + LEAD_SEED, + insertSeed('sys_audit_log', [ + { action: 'update', actor: 'svc:importer', object_name: 'crm_lead', record_id: 'Lisa Thompson' }, + ]), + ] as any, + config: CONFIG, + }); + + expect(result.success).toBe(true); + const leadId = store.crm_lead[0].id; + // The `{object_name, record_id}` index query — the shape the + // `record_views` list view and every "history of this record" drill-down + // issue, with the target's REAL id. + expect( + store.sys_audit_log.filter((r) => r.object_name === 'crm_lead' && r.record_id === leadId), + ).toHaveLength(1); + expect(store.sys_audit_log.filter((r) => r.record_id === 'Lisa Thompson')).toHaveLength(0); + }); + + /** + * MEASURED, and not what this case was first written to assert. + * + * The prediction going in was that an out-of-order pointer would heal in + * pass 2 here, because `sys_audit_log.record_id` is optional and so defers + * cleanly (that is what `sys_activity` does in the order-independence case + * above). It does not, and the reason is a property of the DATASET rather + * than of the pair: an engine-owned ledger has no natural key, so its seed + * dataset declares no `externalId` — and pass 2 back-fills by looking the + * row up by its externalId. It resolves the target and then has nowhere to + * write it ("Deferred reference DROPPED … has an empty externalId, so no + * internal id"). + * + * That is the ruled family direction, not a regression: the load fails + * LOUDLY, and the author's fix is one line (order the target dataset + * first, or declare an externalId on the ledger dataset — the positive + * control below). It is asserted here rather than glossed because it is + * the measured difference between adopting the pair on `sys_activity` and + * adopting it on the four keyless system objects, and a reader who assumed + * order-independence carried over would be wrong. + */ + it('does NOT silently heal an out-of-order pointer on a KEYLESS dataset — pass 2 has nowhere to write back, and says so', async () => { + const { service, store } = newService(ADOPTER_SCHEMAS); + + // Ledger dataset BEFORE its target. A pointer pair contributes no static + // dependency edge (the target is a per-row fact), so nothing but pass 2 + // could save this — and pass 2 cannot, on a dataset with no externalId. + const result = await service.load({ + seeds: [ + insertSeed('sys_audit_log', [ + { action: 'read', object_name: 'crm_lead', record_id: 'Lisa Thompson' }, + ]), + LEAD_SEED, + ] as any, + config: CONFIG, + }); + + expect(result.success).toBe(false); + const err = result.errors.find((e: any) => e.field === 'record_id'); + expect(err).toBeDefined(); + // The message names the cause (the empty externalId), not just the miss — + // the author needs to know ordering/keying is the fix, not the value. + expect(String(err!.message)).toMatch(/externalId/); + // Never the silent verbatim store this family exists to kill. + expect(store.sys_audit_log.filter((r) => r.record_id === 'Lisa Thompson')).toHaveLength(0); + }); + + it('POSITIVE CONTROL — the same out-of-order load DOES heal once the ledger dataset declares an externalId', async () => { + const { service, store } = newService(ADOPTER_SCHEMAS); + + // Same seeds, same order, one difference: the ledger rows are + // addressable. This is what isolates the cause above to keylessness + // rather than to the pointer pair or to the deferral machinery. + const result = await service.load({ + seeds: [ + { + object: 'sys_audit_log', + externalId: 'actor', + mode: 'upsert', + env: ['prod', 'dev', 'test'], + records: [{ action: 'read', actor: 'svc:timeline', object_name: 'crm_lead', record_id: 'Lisa Thompson' }], + }, + LEAD_SEED, + ] as any, + config: CONFIG, + }); + + expect(result.success).toBe(true); + expect(store.sys_audit_log[0].record_id).toBe(store.crm_lead[0].id); + }); + + it('keeps an internal-id-shaped pointer verbatim — how a demo row ABOUT A DELETED record stays authorable', async () => { + const { service, store } = newService(ADOPTER_SCHEMAS); + const goneId = '123e4567-e89b-42d3-a456-426614174000'; + + // An `action: 'delete'` row names a record that by definition no longer + // exists, so it has no natural key to resolve. The landed escape hatch + // is what keeps that row authorable under a declared pair. + const result = await service.load({ + seeds: [ + insertSeed('sys_audit_log', [ + { action: 'delete', object_name: 'crm_lead', record_id: goneId }, + ]), + ] as any, + config: CONFIG, + }); + + expect(result.success).toBe(true); + expect(store.sys_audit_log[0].record_id).toBe(goneId); + }); + }); + + describe('sys_approval_request — object_name / record_id', () => { + it('resolves the pointer so the pending-request lock query finds the row', async () => { + const { service, store } = newService(ADOPTER_SCHEMAS); + + const result = await service.load({ + seeds: [ + LEAD_SEED, + insertSeed('sys_approval_request', [ + { + process_name: 'flow:lead_discount_approval', + object_name: 'crm_lead', + record_id: 'Lisa Thompson', + status: 'pending', + }, + ]), + ] as any, + config: CONFIG, + }); + + expect(result.success).toBe(true); + const leadId = store.crm_lead[0].id; + // `lifecycle-hooks.ts` holds the record LOCK with exactly this where — + // and `approval-service.ts` finds a record's open request with it. A + // verbatim natural key matched neither, so the seeded request locked + // nothing while looking pending. + expect( + store.sys_approval_request.filter( + (r) => r.object_name === 'crm_lead' && r.record_id === leadId && r.status === 'pending', + ), + ).toHaveLength(1); + }); + + it('REFUSES a request pointing at a record that does not exist', async () => { + const { service, store, logger } = newService(ADOPTER_SCHEMAS); + + const result = await service.load({ + seeds: [ + LEAD_SEED, + insertSeed('sys_approval_request', [ + { + process_name: 'flow:lead_discount_approval', + object_name: 'crm_lead', + record_id: 'No Such Lead', + status: 'pending', + }, + ]), + ] as any, + config: CONFIG, + }); + + expect(result.success).toBe(false); + expect( + result.errors.some((e: any) => e.field === 'record_id' && String(e.message).includes('crm_lead')), + ).toBe(true); + expect(logger.error).toHaveBeenCalled(); + expect(store.sys_approval_request.filter((r) => r.record_id === 'No Such Lead')).toHaveLength(0); + }); + }); + + describe('sys_record_share — object_name / record_id', () => { + it('resolves the grant pointer so the enforcement lookup matches the shared record', async () => { + const { service, store } = newService(ADOPTER_SCHEMAS); + + const result = await service.load({ + seeds: [ + LEAD_SEED, + insertSeed('sys_record_share', [ + { + object_name: 'crm_lead', + record_id: 'Lisa Thompson', + recipient_type: 'user', + recipient_id: 'usr_agent', + access_level: 'edit', + source: 'manual', + }, + ]), + ] as any, + config: CONFIG, + }); + + expect(result.success).toBe(true); + const leadId = store.crm_lead[0].id; + // The middleware asks "which record ids is this principal granted?" — a + // grant whose `record_id` is a natural key answers with a string that is + // no record's id, so it widens access to nothing while displaying as a + // grant on Setup → Record Shares. + expect( + store.sys_record_share.filter( + (r) => r.object_name === 'crm_lead' && r.record_id === leadId && r.recipient_id === 'usr_agent', + ), + ).toHaveLength(1); + }); + + it('REFUSES a grant on a record that does not exist — instead of a row the orphan sweep would delete', async () => { + const { service, store, logger } = newService(ADOPTER_SCHEMAS); + + const result = await service.load({ + seeds: [ + LEAD_SEED, + insertSeed('sys_record_share', [ + { + object_name: 'crm_lead', + record_id: 'No Such Lead', + recipient_type: 'user', + recipient_id: 'usr_agent', + access_level: 'read', + source: 'manual', + }, + ]), + ] as any, + config: CONFIG, + }); + + // `record-orphan-cleanup.ts`: "record gone ⇒ the row cannot describe any + // access at all" — the sweep deletes such a row. Refusing at seed time + // reports the defect while the author is still looking at it. + expect(result.success).toBe(false); + expect(result.errors.some((e: any) => e.field === 'record_id')).toBe(true); + expect(logger.error).toHaveBeenCalled(); + expect(store.sys_record_share.filter((r) => r.record_id === 'No Such Lead')).toHaveLength(0); + }); + }); + + describe('sys_share_link — object_name / record_id', () => { + it('resolves the link pointer so the fail-closed record-existence gate passes', async () => { + const { service, store } = newService(ADOPTER_SCHEMAS); + + const result = await service.load({ + seeds: [ + LEAD_SEED, + insertSeed('sys_share_link', [ + { + token: 'tok_demo_readonly', + object_name: 'crm_lead', + record_id: 'Lisa Thompson', + permission: 'read', + audience: 'anyone_with_link', + }, + ]), + ] as any, + config: CONFIG, + }); + + expect(result.success).toBe(true); + const leadId = store.crm_lead[0].id; + const link = store.sys_share_link[0]; + expect(link.record_id).toBe(leadId); + // `share-link-service.ts` resolves a token through + // `recordStillExists(object_name, record_id)` and returns null when it + // misses — the same null as revoked/expired. This is that probe. + expect(store.crm_lead.filter((r) => r.id === link.record_id)).toHaveLength(1); + }); + + it('REFUSES a link whose target cannot be resolved — a token that would 404 as if revoked', async () => { + const { service, store, logger } = newService(ADOPTER_SCHEMAS); + + const result = await service.load({ + seeds: [ + LEAD_SEED, + insertSeed('sys_share_link', [ + { + token: 'tok_dead_on_arrival', + object_name: 'crm_lead', + record_id: 'No Such Lead', + permission: 'read', + audience: 'anyone_with_link', + }, + ]), + ] as any, + config: CONFIG, + }); + + expect(result.success).toBe(false); + expect(result.errors.some((e: any) => e.field === 'record_id')).toBe(true); + expect(logger.error).toHaveBeenCalled(); + expect(store.sys_share_link.filter((r) => r.record_id === 'No Such Lead')).toHaveLength(0); + }); + }); + + it('leaves the UNADOPTED fifth object alone: sys_automation_run stores its trigger pointer verbatim', async () => { + const { service, store } = newService({ + ...ADOPTER_SCHEMAS, + // Mirrors the real declaration: the trigger pair carries NO + // `referenceVia` — the deliberate verdict recorded on the object. + sys_automation_run: { + name: 'sys_automation_run', + fields: { + flow_name: { type: 'text', required: true }, + status: { type: 'select' }, + trigger_object: { type: 'text' }, + trigger_record_id: { type: 'text' }, + }, + }, + }); + + const result = await service.load({ + seeds: [ + LEAD_SEED, + insertSeed('sys_automation_run', [ + { flow_name: 'lead_scoring', status: 'completed', trigger_object: 'crm_lead', trigger_record_id: 'Lisa Thompson' }, + ]), + ] as any, + config: CONFIG, + }); + + // Undeclared stays undeclared: adopting four objects must not quietly + // change the fifth by column spelling. If a later card rules that runs + // ARE seedable, this expectation is what it has to come and change. + expect(result.success).toBe(true); + expect(store.sys_automation_run[0].trigger_record_id).toBe('Lisa Thompson'); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request-pointer-pair.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request-pointer-pair.test.ts new file mode 100644 index 0000000000..23af10da51 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/sys-approval-request-pointer-pair.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SysApprovalRequest } from './sys-approval-request.object.js'; + +/** + * `sys_approval_request` declares the ActivityPointer pair — #11386 (ADR-0052 + * §5, carrier landed by #11339). + * + * Re-verified for THIS object rather than inherited from the shape it shares + * with the audit ledger: here the pair is not a display key, it is the key the + * approval machinery QUERIES ON. `approval-service.ts` finds a record's open + * request with `where: { object_name, record_id, status: 'pending' }`, and + * `lifecycle-hooks.ts` holds the record LOCK on the same pair (single and + * `$in` batch forms). `submit()` writes both halves from `input.object` / + * `input.recordId`, so a stored id is always a record id of the object the + * sibling names. + * + * Consequence, and why the declaration earns its place on this object: a + * seeded request whose `record_id` stayed a verbatim natural key locked + * nothing and surfaced under no record — a row that LOOKED pending while being + * invisible to both queries that give it meaning. + */ +describe('sys_approval_request — declared pointer pair (#11386)', () => { + const fields = SysApprovalRequest.fields as Record>; + + it('declares record_id as the id half, resolved through object_name', () => { + expect(fields.record_id.referenceVia).toBe('object_name'); + }); + + it('keeps the id half a plain text column with no contradicting static reference', () => { + expect(fields.record_id.type).toBe('text'); + expect(fields.record_id.reference).toBeUndefined(); + }); + + it('declares the sibling the pointer names', () => { + expect(fields.object_name).toBeDefined(); + expect(fields.object_name.type).toBe('text'); + }); + + it('keeps BOTH halves required — so an un-addressable pointer cannot be authored here at all', () => { + // The loader's "id half authored, object half empty" refusal is reachable + // on objects whose halves are optional (`sys_audit_log`). On this object + // the schema forecloses it one layer earlier. Recorded because it is the + // measured difference between two objects carrying the same pair — and + // because `required: true` is also why a deferred pointer here cannot be + // back-filled by pass 2 the way an optional one is. + expect(fields.record_id.required).toBe(true); + expect(fields.object_name.required).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts index 07bda77b12..c2471202c7 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts @@ -142,7 +142,25 @@ export const SysApprovalRequest = ObjectSchema.create({ label: 'Record ID', required: true, maxLength: 100, + // [#11386] The id half of this object's pointer pair (ADR-0052 §5), + // adopting the #11339 carrier. VERIFIED for THIS object: the pair is not + // decoration but the key the approval machinery QUERIES ON — + // `approval-service.ts` finds a record's pending request with + // `where: { object_name, record_id, status: 'pending' }`, and + // `lifecycle-hooks.ts` holds the record LOCK on the same pair + // (single-record and `$in` batch forms). `submit()` writes it from + // `input.object` / `input.recordId`, so a stored value is always a + // record id of the object the sibling names. + // + // Consequence of declaring, sharper here than elsewhere: a seeded + // request whose `record_id` stayed a verbatim natural key locked + // NOTHING and appeared under no record — it looked like a pending + // approval while being invisible to both queries that give the row its + // meaning. That is now a loud seed-time refusal. Both halves are + // `required: true`, so the un-addressable case (id half authored, type + // half empty) is already unreachable on this object. group: 'Target', + referenceVia: 'object_name', }), submitter_id: Field.lookup('sys_user', { diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts index 8b4fa30e16..4a79e14ca5 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts @@ -232,6 +232,27 @@ export const SysAuditLog = ObjectSchema.create({ readonly: true, searchable: true, description: 'ID of the affected record', + // [#11386] The id half of this object's ActivityPointer pair (ADR-0052 + // §5), adopting the #11339 carrier. VERIFIED for THIS object rather than + // assumed from the shape: every writer of the pair stamps an object + // MACHINE NAME beside a real record id of that object — + // `audit-writers.ts` (`object_name: ctx.object`, `record_id: recordId`), + // `read-audit.ts` (`event.objectName` / `event.recordId`), + // `auth-event-audit.ts` (the session object + `event.sessionId`) and + // plugin-auth's admin user endpoints (`'sys_user'` + the affected user + // id). The pair is also the object's own query key: the + // `{object_name, record_id}` index and the `record_views` list view + // answer "who touched THIS record", which only matches on the target's + // real id. + // + // Consequence of declaring, accepted deliberately: a seeded ledger row + // whose pointer names a record that cannot be resolved is now refused + // loudly instead of stored verbatim — the verbatim row was one that + // matched no pair query and rendered on no drill-down. A demo row ABOUT + // A DELETED RECORD (an `action: 'delete'` row, whose target must NOT + // exist) stays authorable through the landed escape hatch: an + // internal-id-shaped value passes through untouched. + referenceVia: 'object_name', group: 'Target', }), diff --git a/packages/plugins/plugin-audit/src/sys-audit-log-pointer-pair.test.ts b/packages/plugins/plugin-audit/src/sys-audit-log-pointer-pair.test.ts new file mode 100644 index 0000000000..952a07e6a8 --- /dev/null +++ b/packages/plugins/plugin-audit/src/sys-audit-log-pointer-pair.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SysAuditLog } from './objects/sys-audit-log.object.js'; + +/** + * `sys_audit_log` declares the ActivityPointer pair — #11386 (ADR-0052 §5, + * carrier landed by #11339). + * + * This pin lives HERE, next to the object, rather than only in the seed + * loader's own suite, because the loader's tests mirror this shape in a fake + * schema map. A mirror that drifts from the real declaration keeps passing + * while the platform stops resolving the pair — the pin closes that gap by + * asserting what the object ACTUALLY declares. + * + * The evidence that this pair is a pointer and not two coincidental columns + * (re-verified for THIS object, per the card's per-object discipline): every + * writer stamps an object MACHINE NAME beside a real record id of that object + * — `audit-writers.ts` (`object_name: ctx.object` / `record_id: recordId`), + * `read-audit.ts` (`event.objectName` / `event.recordId`), + * `auth-event-audit.ts` (the session object / `event.sessionId`), and + * plugin-auth's admin user endpoints (`'sys_user'` / the affected user id). + * The `{object_name, record_id}` index and the `record_views` list view read + * the pair back as "who touched THIS record". + */ +describe('sys_audit_log — declared pointer pair (#11386)', () => { + const fields = SysAuditLog.fields as Record>; + + it('declares record_id as the id half, resolved through object_name', () => { + expect(fields.record_id.referenceVia).toBe('object_name'); + }); + + it('keeps the id half a plain text column — `referenceVia` is text-only, and a static `reference` would contradict it', () => { + expect(fields.record_id.type).toBe('text'); + expect(fields.record_id.reference).toBeUndefined(); + }); + + it('declares the sibling the pointer names, holding an object machine name', () => { + // `ObjectSchema.create` refuses a `referenceVia` whose sibling is not + // declared, so this is belt-and-braces — but it is also the line a future + // rename of `object_name` will trip over, which is the point. + expect(fields.object_name).toBeDefined(); + expect(fields.object_name.type).toBe('text'); + }); + + it('leaves the id half OPTIONAL — run-level rows (an `import` row) carry no record', () => { + // Load-bearing for the pair's seed contract: because the column is + // optional, an out-of-order seed pointer can be deferred and back-filled + // rather than failing the insert outright. It is also why "id half + // authored, object half empty" is reachable here and refused loudly. + expect(fields.record_id.required ?? false).toBe(false); + expect(fields.object_name.required ?? false).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts index 6b91c6a432..71d886ca96 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts @@ -132,7 +132,25 @@ export const SysRecordShare = ObjectSchema.create({ required: true, maxLength: 100, description: 'Primary key of the shared record within object_name', + // [#11386] The id half of this object's pointer pair (ADR-0052 §5), + // adopting the #11339 carrier. VERIFIED for THIS object: the pair is + // dereferenced as a real record address, not stored as a label — + // `record-orphan-cleanup.ts` states the invariant for both tables in + // this package ("record gone ⇒ the row cannot describe any access at + // all") and sweeps rows by asking, per row, whether `(object_name, + // record_id)` still exists; `sharing-service.ts` writes it from + // `input.object` / `input.recordId` and gates management on the same + // pair. + // + // Consequence of declaring, and why it matters MORE on a grant table + // than on a log: an unresolved verbatim `record_id` did not merely fail + // to display. It named no live record, so it enforced no access while + // showing on Setup → Record Shares as though it did — and the orphan + // sweep then DELETED the row for describing a record that does not + // exist. Loud seed-time refusal replaces a grant that silently meant + // nothing and then silently disappeared. group: 'Target', + referenceVia: 'object_name', }), // ── Recipient (who receives access) ────────────────────────── diff --git a/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts index e1ed00fbf4..f52b44a668 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts @@ -124,7 +124,24 @@ export const SysShareLink = ObjectSchema.create({ required: true, maxLength: 100, description: 'Primary key of the shared record within object_name', + // [#11386] The id half of this object's pointer pair (ADR-0052 §5), + // adopting the #11339 carrier. VERIFIED for THIS object separately from + // its `sys_record_share` sibling, because the consuming code path is a + // different one: `share-link-routes.ts` calls + // `engine.find(link.object_name, …)` with `record_id` as the address, + // and `share-link-service.ts` resolves a token through a FAIL-CLOSED + // existence gate — `if (!(await this.recordStillExists(row.object_name, + // row.record_id))) return null`. + // + // That gate is what makes the declaration worth more here than + // anywhere else in this family: a seeded link whose `record_id` stayed a + // verbatim natural key was a permanently DEAD token, and dead in the + // most misleading way available — the gate deliberately returns the same + // `null` as revoked/expired (so the endpoint leaks nothing), so the + // failure is silent at seed time and indistinguishable from a + // revocation at use time. Declared, the seed is refused loudly instead. group: 'Target', + referenceVia: 'object_name', }), // ── Access Policy ──────────────────────────────────────────── diff --git a/packages/plugins/plugin-sharing/src/sharing-pointer-pairs.test.ts b/packages/plugins/plugin-sharing/src/sharing-pointer-pairs.test.ts new file mode 100644 index 0000000000..525e6ecc99 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/sharing-pointer-pairs.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SysRecordShare } from './objects/sys-record-share.object.js'; +import { SysShareLink } from './objects/sys-share-link.object.js'; + +/** + * The two sharing tables declare the ActivityPointer pair — #11386 (ADR-0052 + * §5, carrier landed by #11339). + * + * They live in one file because they live in one package, NOT because they + * were adopted as one decision: `record-orphan-cleanup.ts` states the shared + * invariant ("record gone ⇒ the row cannot describe any access at all"), but + * each table reaches it down a different path, and each was re-verified on its + * own — see the per-object blocks below. + * + * What both share, and what makes the declaration matter more here than on a + * log: an unresolved `record_id` on a grant table does not merely fail to + * DISPLAY. It names no live record, so the row enforces nothing while + * appearing to be a grant — and then the orphan sweep deletes it for + * describing a record that does not exist. + */ +describe('sys_record_share — declared pointer pair (#11386)', () => { + const fields = SysRecordShare.fields as Record>; + + it('declares record_id as the id half, resolved through object_name', () => { + // Dereferenced as a real address, not stored as a label: + // `sharing-service.ts` writes the pair from `input.object` / + // `input.recordId` and gates share management on it, and the orphan sweep + // asks per row whether `(object_name, record_id)` still exists. + expect(fields.record_id.referenceVia).toBe('object_name'); + }); + + it('keeps the id half a plain text column with no contradicting static reference', () => { + expect(fields.record_id.type).toBe('text'); + expect(fields.record_id.reference).toBeUndefined(); + }); + + it('declares the sibling the pointer names', () => { + expect(fields.object_name).toBeDefined(); + expect(fields.object_name.type).toBe('text'); + }); + + it('does NOT declare source_id as a pointer half — it names a sharing RULE, not a record', () => { + // The measured near-miss on this object: `source_id` sits beside the pair, + // is text, and reads like an id half. It is the reconciliation handle for + // the rule that materialised the grant — no sibling column names an object + // for it, and resolving it as a record id would be a fabricated pointer. + expect(fields.source_id).toBeDefined(); + expect(fields.source_id.referenceVia).toBeUndefined(); + }); +}); + +describe('sys_share_link — declared pointer pair (#11386)', () => { + const fields = SysShareLink.fields as Record>; + + it('declares record_id as the id half, resolved through object_name', () => { + // Verified through THIS object's own consumer, which is not the sharing + // middleware: `share-link-routes.ts` calls `engine.find(link.object_name, + // …)` with `record_id` as the address, and `share-link-service.ts` + // resolves a token through the fail-closed gate + // `if (!(await this.recordStillExists(row.object_name, row.record_id))) return null`. + expect(fields.record_id.referenceVia).toBe('object_name'); + }); + + it('keeps the id half a plain text column with no contradicting static reference', () => { + expect(fields.record_id.type).toBe('text'); + expect(fields.record_id.reference).toBeUndefined(); + }); + + it('declares the sibling the pointer names', () => { + expect(fields.object_name).toBeDefined(); + expect(fields.object_name.type).toBe('text'); + }); + + it('does NOT declare token as a pointer half — a capability secret is not a record address', () => { + expect(fields.token).toBeDefined(); + expect(fields.token.referenceVia).toBeUndefined(); + }); +}); diff --git a/packages/services/service-automation/src/sys-automation-run-pointer-pair-verdict.test.ts b/packages/services/service-automation/src/sys-automation-run-pointer-pair-verdict.test.ts new file mode 100644 index 0000000000..4023bac75f --- /dev/null +++ b/packages/services/service-automation/src/sys-automation-run-pointer-pair-verdict.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SysAutomationRun } from './sys-automation-run.object.js'; + +/** + * `sys_automation_run` — the DELIBERATE "stays undeclared" verdict on the + * `trigger_object` / `trigger_record_id` pair (#11386, the fifth of the five + * objects that card surveyed; carrier landed by #11339, ADR-0052 §5). + * + * #11386's acceptance sketch admits two outcomes per object: declare + * `referenceVia`, or record a deliberate "stays undeclared" verdict with the + * reason. Four objects took the first route. This one takes the second, and + * this file is what makes that verdict FALSIFIABLE rather than an absence + * nobody can see — a later sweep that declares the pair by shape has to come + * here, read the reason, and rule against it on purpose. + * + * The SHAPE fits and was verified: `SuspendedRunStore.serialize()` stamps + * `trigger_object: ctx.object` beside `trigger_record_id: ctx.record.id`, and + * the `{trigger_object, trigger_record_id}` index reads the pair back as an + * address. What does not fit is the only thing the declaration enforces today + * — seed-time resolution of an AUTHORED pointer: + * + * - a `paused` row IS a live continuation. The store loads every + * `{ status: 'paused' }` row on boot and rehydrates it, so an authored one + * is not sample data but a fabricated continuation the engine will try to + * resume against snapshots no real run produced. + * - a terminal row is telemetry under this object's own retention contract + * (`class: 'telemetry'`, 30d sweep scoped to completed/failed), so seeded + * run history deletes itself on the first Reaper pass that reaches its age. + * - the object has no natural key to be addressed BY: `nameField: 'id'`, the + * id is the engine's raw `runId`, and there is no `name` field at all. + * + * Declaring would not make a real corpus resolvable; it would advertise run + * rows as authorable seed content. To flip it: land a consumer that reads the + * pair for something other than seed authoring (#5180's delete-cascade carrier + * is the live candidate), or a measured case for seeding runs. + */ +describe('sys_automation_run — pointer pair stays undeclared (#11386 verdict)', () => { + const fields = SysAutomationRun.fields as Record>; + + it('carries the pair as plain columns: trigger_record_id declares no referenceVia', () => { + expect(fields.trigger_record_id).toBeDefined(); + expect(fields.trigger_record_id.type).toBe('text'); + expect(fields.trigger_record_id.referenceVia).toBeUndefined(); + }); + + it('declares no referenceVia anywhere on the object', () => { + // Guards the whole object, not just the one column the card named — the + // verdict is about run rows not being authorable content, which is not a + // fact about a single field. + const declared = Object.entries(fields) + .filter(([, def]) => def?.referenceVia !== undefined) + .map(([name]) => name); + expect(declared).toEqual([]); + }); + + it('still carries the object half, so the verdict is about authorability — not a missing pair', () => { + // If this ever fails, the pair itself changed shape and the verdict above + // needs re-deriving from scratch rather than re-affirming. + expect(fields.trigger_object).toBeDefined(); + expect(fields.trigger_object.type).toBe('text'); + }); + + it('has no natural key a seed dataset could address its rows by — one of the verdict\'s measured legs', () => { + expect(fields.name).toBeUndefined(); + expect(SysAutomationRun.nameField).toBe('id'); + }); +}); diff --git a/packages/services/service-automation/src/sys-automation-run.object.ts b/packages/services/service-automation/src/sys-automation-run.object.ts index 238d3cbc7c..56d99714db 100644 --- a/packages/services/service-automation/src/sys-automation-run.object.ts +++ b/packages/services/service-automation/src/sys-automation-run.object.ts @@ -182,6 +182,43 @@ export const SysAutomationRun = ObjectSchema.create({ group: 'Trigger', }), + // [#11386] DELIBERATELY NOT DECLARED `referenceVia: 'trigger_object'` — + // the recorded "stays undeclared" verdict the card's acceptance sketch + // asks for, kept at the site so the next survey of this idiom finds the + // reasoning instead of re-deriving it (or sweeping it). + // + // The SHAPE fits: `serialize()` stamps `trigger_object: ctx.object` beside + // `trigger_record_id: ctx.record.id`, so this really is the same (object + // half, id half) pointer `sys_activity`, `sys_audit_log`, + // `sys_approval_request`, `sys_record_share` and `sys_share_link` carry, + // and the `{trigger_object, trigger_record_id}` index queries it the same + // way. The four objects adopted in #11386 declared on exactly that basis. + // + // What does NOT fit is the only thing the declaration currently ENFORCES: + // seed-time resolution of an AUTHORED pointer. A `sys_automation_run` row + // is not content about a record — it is the engine's own run state: + // - a `paused` row IS a live continuation. `SuspendedRunStore` loads + // every `{ status: 'paused' }` row on boot and rehydrates it, so an + // authored one is not sample data, it is a fabricated continuation the + // engine will try to resume against `variables_json` / `steps_json` / + // `context_json` snapshots no real run produced. + // - a terminal row is telemetry under this object's own retention + // contract (`class: 'telemetry'`, 30d sweep scoped to + // completed/failed), so seeded run history deletes itself on the first + // Reaper pass that reaches its age. + // - the row has no natural key to be addressed BY: `nameField: 'id'`, + // the id is the engine's raw `runId`, and the object declares no `name` + // field at all — the seed loader's default externalId does not exist + // here. + // Declaring would therefore not make a real corpus resolvable; it would + // advertise run rows as authorable seed content — precisely the wrong + // signal for a metadata author (human or AI) reading the declaration as + // permission. + // + // To flip this: land a consumer that reads the pair for something other + // than seed authoring (the #5180 delete-cascade carrier is the live + // candidate — that card needs to know which columns form the pointer), + // or a measured case for seeding runs. Not by sweep. trigger_record_id: Field.text({ label: 'Trigger Record', required: false,