Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/pointer-pair-adoption-five-objects.md
Original file line numberDiff line numberDiff line change
@@ -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.
440 changes: 440 additions & 0 deletions packages/metadata-protocol/src/seed-loader-pointer-pair.test.ts

Large diffs are not rendered by default.

Original file line numberDiff line numberDiff line change
@@ -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<string, Record<string, unknown>>;

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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
}),

Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<string, Record<string, unknown>>;

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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) ──────────────────────────
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ────────────────────────────────────────────
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<string, Record<string, unknown>>;

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<string, Record<string, unknown>>;

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();
});
});
Loading
Loading