Skip to content

Commit 1a53a02

Browse files
baozhoutaoclaude
andauthored
fix(meta): /meta object reads report the audit-field governance the write path enforces (#4513) (#6561)
* fix(meta): /meta object reads report the audit governance the write path enforces (#4513) The read surface resolved object documents through the sys_metadata overlay and MetadataService before the SchemaRegistry, and only the registry has been through applySystemFields — so a materialized created_at carrying FieldSchema defaults reported readonly: false while ObjectQL.update was refusing writes to that same field (#4447 closed the write half). The audit-family governance table moves to @objectstack/metadata-core, the one package both objectql and metadata-protocol depend on, by the same criterion as the #5619 dispatch predicates; every /meta object read exit now applies it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDU3qAuJyajAQm3GkUXdfA * test(objectql): type the new #4513 call sites so the TEST_DEBT ratchet stays at its floor check:query-options-erasure counted a new `findOne(..., {...} as any)` against the test-surface ceiling, and check:type-check-debt measured objectql's hidden test layer at +2. Both are shrink-only ratchets, so the new lines pay their own way: the findOne options bag drops its erasure (the call is on contract and the signature infers it), registerObject passes its required packageId, and the getObject reads use optional chaining. Measured 353 vs the ledger's 355. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDU3qAuJyajAQm3GkUXdfA --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 89a0b6a commit 1a53a02

7 files changed

Lines changed: 731 additions & 15 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/metadata-core": patch
3+
"@objectstack/metadata-protocol": patch
4+
"@objectstack/objectql": patch
5+
---
6+
7+
fix(meta): `/meta` object reads stop reporting `readonly: false` on fields the write path refuses (#4513)
8+
9+
`#4447` made the audit-provenance family (`created_at`, `created_by`,
10+
`updated_at`, `updated_by`) engine-owned on the **write** path: the registry's
11+
`applySystemFields` forces `{ readonly: true, system: true }` over a *declared*
12+
audit field, and `ObjectQL.update` strips a non-system caller's write to it.
13+
14+
The **read** path never learned it. A `/meta` object read resolves through
15+
`sys_metadata` overlay → MetadataService → SchemaRegistry, and only the last of
16+
those three has been through `applySystemFields` — so an object whose built
17+
artifact ships a materialized `created_at` carrying FieldSchema defaults
18+
(`readonly: false`) reported that value to every client while writes to that
19+
same field were being refused. Measured before the fix, all of the read exits
20+
agreed with each other and disagreed with the engine:
21+
22+
```
23+
single read: {"type":"datetime","label":"Created At","readonly":false}
24+
list read: {"type":"datetime","label":"Created At","readonly":false}
25+
cached read: {"type":"datetime","label":"Created At","readonly":false}
26+
layered read: {"type":"datetime","label":"Created At","readonly":false}
27+
```
28+
29+
One field, two answers — and the machine-readable one, the only face a client
30+
or an AI author writing code off `/meta` can see, was the wrong one.
31+
32+
**What changes.** Every `/meta` object read exit now reports the audit family
33+
the way the engine enforces it. That covers the single-item read (both the
34+
singular and plural type spelling), the list read, the cached/ETag branch, the
35+
`?preview=draft` and `?state=draft` reads, and the layered read's `effective`
36+
layer. `GET` bodies for objects that declare an audit field will show
37+
`readonly: true, system: true` where they previously showed `readonly: false`
38+
or omitted the keys; nothing else about the document changes, and the ETag for
39+
such an object changes once.
40+
41+
**What deliberately does not change.**
42+
43+
- The layered read's `code` and `overlay` layers stay raw — showing the
44+
package's declaration beside the governed `effective` value is the
45+
diagnostic's whole point.
46+
- `sys_metadata` still stores exactly what the author saved; the correction is
47+
applied on the way out, so no phantom customization appears in the diff.
48+
- An object that opts out of the audit family (`systemFields: false`,
49+
`systemFields.audit: false`, `managedBy: 'better-auth'`) is untouched — the
50+
engine enforces nothing there, so a read that claimed otherwise would be the
51+
same lie pointing the other way.
52+
- Only `readonly` and `system` are forced. Every other key an author writes —
53+
`label`, `description`, `hidden`, `group`, and `type` for an external object
54+
mapping a differently-typed remote column — stays theirs.
55+
56+
The governance table moved from `packages/objectql/src/registry.ts` to
57+
`@objectstack/metadata-core` (`AUDIT_FIELD_GOVERNANCE`, plus the
58+
`applyAuditFieldGovernance` normalizer the read path applies), by the same
59+
criterion and for the same cycle as the `#5619` engine-dispatch predicates:
60+
`@objectstack/objectql` depends on `@objectstack/metadata-protocol`, so the
61+
read path cannot import the table from the registry that enforces it, and a
62+
second copy would agree only until someone edited one side. `objectql`
63+
re-exports the symbol from its original path, so its public API is unchanged.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The **one** answer to "which keys on an object's audit-provenance columns are
5+
* platform-owned rather than authorable?" — and the normalizer that applies
6+
* that answer to a metadata document (objectstack#4513, from objectstack#4447).
7+
*
8+
* ## What #4447 established, and the half it left open
9+
*
10+
* `applySystemFields` (`@objectstack/objectql`) injects the
11+
* {@link AUDIT_PROVENANCE_FIELDS} family and, since #4447, **forces**
12+
* `readonly: true` / `system: true` over a *declared* audit field as well:
13+
* `fields: { ...additions, ...schema.fields, ...overrides }`. That is what the
14+
* write path enforces — `ObjectQL.update` strips a non-system caller's write to
15+
* a statically-`readonly` field off the registry's post-injection schema, so a
16+
* forged `created_at` is refused whatever the author declared.
17+
*
18+
* The **read** path never learned it. `GET /api/v1/meta/objects/:name` answers
19+
* from `sys_metadata` (the stored overlay / build-artifact body) first and
20+
* consults the registry only as a fallback, so an object whose artifact ships a
21+
* materialized `created_at` carrying FieldSchema DEFAULTS (`readonly: false`)
22+
* reported `readonly: false` to every client while writes to that same field
23+
* were being refused. Measured on `origin/main` before this module existed, all
24+
* four protocol read exits agreed with each other and disagreed with the
25+
* engine:
26+
*
27+
* ```
28+
* single read: {"type":"datetime","label":"Created At","readonly":false}
29+
* list read: {"type":"datetime","label":"Created At","readonly":false}
30+
* cached read: {"type":"datetime","label":"Created At","readonly":false}
31+
* layered read: {"type":"datetime","label":"Created At","readonly":false}
32+
* ```
33+
*
34+
* One field, two answers, and the machine-readable one was the wrong one — the
35+
* face that clients, and AI authors writing code against `/meta`, are the only
36+
* ones able to see.
37+
*
38+
* ## Why this module lives in `@objectstack/metadata-core`
39+
*
40+
* The same criterion `engine-delete-dispatch.ts` records, for the same cycle:
41+
* `@objectstack/objectql` **depends on** `@objectstack/metadata-protocol`, so
42+
* the read path cannot import the governance table from the registry that owns
43+
* it. When a reverse import is impossible, the only honest way out is to sink
44+
* the contract into a package **both sides already depend on** — and this
45+
* package's own dependencies are `{ @objectstack/spec, zod }`, so there is no
46+
* new edge and no new cycle.
47+
*
48+
* The alternative — a second governance table inside the read path — is exactly
49+
* the drift this repo keeps paying for: the read would agree with the write
50+
* only until someone edited one side, which is the state #4513 records.
51+
*
52+
* ## What it deliberately does NOT do
53+
*
54+
* - **It governs only DECLARED audit fields.** `applySystemFields` *injects* an
55+
* absent one; this normalizer does not, because the served document is the
56+
* authored metadata document and injecting columns into it would rewrite what
57+
* a `GET` → `PUT` round-trip persists (the #4326 invariant) and what the
58+
* layered read reports as "customised". An absent field does not claim
59+
* `readonly: false`, so it is not the lie #4513 names.
60+
* - **It governs only the audit family.** A declared `organization_id` /
61+
* `owner_id` / `owning_business_unit_id` is the author's field and the
62+
* registry lets it win (those are `additions`, not `overrides`), so reporting
63+
* the author's value for them already agrees with what the write path
64+
* enforces. Forcing them here would create the mismatch in the other
65+
* direction.
66+
*/
67+
68+
import {
69+
AUDIT_PROVENANCE_FIELDS,
70+
resolveInjectedSystemColumns,
71+
type AuditProvenanceField,
72+
} from '@objectstack/spec/data';
73+
74+
/**
75+
* The subset of an audit column's definition that is NOT authorable — the keys
76+
* that decide **who may write** the column.
77+
*
78+
* Only `readonly` / `system` travel: everything else an author writes —
79+
* `label`, `description`, `hidden`, `group`, and even `type` for an external
80+
* object mapping a differently-typed remote column — stays theirs. Narrower is
81+
* the point: this overrides an author, so it takes only what the defect
82+
* requires (#4447).
83+
*
84+
* Keyed by the spec's {@link AUDIT_PROVENANCE_FIELDS} tuple, so a name added
85+
* there without an entry here — or an entry for a name the spec dropped — is a
86+
* compile error rather than a silently diverging copy.
87+
*/
88+
export const AUDIT_FIELD_GOVERNANCE: Record<AuditProvenanceField, Record<string, unknown>> =
89+
Object.fromEntries(
90+
AUDIT_PROVENANCE_FIELDS.map((name) => [name, { readonly: true, system: true }]),
91+
) as unknown as Record<AuditProvenanceField, Record<string, unknown>>;
92+
93+
/** Does this field definition already carry every governance key at its governed value? */
94+
function isGoverned(declared: unknown, governance: Record<string, unknown>): boolean {
95+
if (!declared || typeof declared !== 'object' || Array.isArray(declared)) return false;
96+
const rec = declared as Record<string, unknown>;
97+
for (const [key, value] of Object.entries(governance)) {
98+
if (rec[key] !== value) return false;
99+
}
100+
return true;
101+
}
102+
103+
/**
104+
* Force {@link AUDIT_FIELD_GOVERNANCE} onto every audit-provenance field the
105+
* document declares, so what a reader is told about who may write the column
106+
* matches what the engine enforces.
107+
*
108+
* Pure and total, with the same tolerance contract as
109+
* {@link resolveInjectedSystemColumns}: any input may be handed to it,
110+
* including a bare record that has never been through Zod. Objects that opt out
111+
* of the audit family (`systemFields: false`, `systemFields.audit: false`,
112+
* `managedBy: 'better-auth'`) carry no platform governance and are returned
113+
* untouched — the same rows `applySystemFields` skips.
114+
*
115+
* Returns the **same reference** when nothing needed forcing, so a read path
116+
* that already agrees with the engine (a registry-sourced document, which went
117+
* through `applySystemFields` at registration) pays one comparison and no copy.
118+
*
119+
* @param doc An object metadata document, or any bare record shaped like one.
120+
*/
121+
export function applyAuditFieldGovernance<T>(doc: T): T {
122+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return doc;
123+
const rec = doc as unknown as Record<string, unknown>;
124+
const fields = rec.fields;
125+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return doc;
126+
127+
// WHICH columns this object carries is the spec's derivation — the same one
128+
// `applySystemFields` consumes. Re-deriving the opt-out conditions here is
129+
// precisely the drift this module exists to prevent.
130+
if (!resolveInjectedSystemColumns(rec).audit) return doc;
131+
132+
const declaredFields = fields as Record<string, unknown>;
133+
let governed: Record<string, unknown> | undefined;
134+
for (const name of AUDIT_PROVENANCE_FIELDS) {
135+
const declared = declaredFields[name];
136+
// Absent is not a lie — see the module header. Only a DECLARED audit field
137+
// can claim a writability the engine refuses.
138+
if (declared === undefined || declared === null) continue;
139+
if (isGoverned(declared, AUDIT_FIELD_GOVERNANCE[name])) continue;
140+
governed ??= { ...declaredFields };
141+
governed[name] = typeof declared === 'object' && !Array.isArray(declared)
142+
? { ...(declared as Record<string, unknown>), ...AUDIT_FIELD_GOVERNANCE[name] }
143+
: { ...AUDIT_FIELD_GOVERNANCE[name] };
144+
}
145+
146+
if (governed === undefined) return doc;
147+
return { ...rec, fields: governed } as unknown as T;
148+
}

packages/metadata-core/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,12 @@ export * from './objects/index.js';
2626
// See `scripts/check-engine-double-contract.mjs` — the gate over the doubles.
2727
export * from './engine-delete-dispatch.js';
2828
export * from './engine-update-dispatch.js';
29+
30+
// [#4513] The audit-family GOVERNANCE table (#4447) and its normalizer, sunk
31+
// here for the same reason and by the same criterion as the two dispatch
32+
// predicates above: the `/meta` READ path lives in
33+
// `@objectstack/metadata-protocol`, which `@objectstack/objectql` depends on,
34+
// so it cannot import the table from the registry that enforces it. The read
35+
// surface and the write path now derive one answer from one table instead of
36+
// reporting two.
37+
export * from './audit-field-governance.js';

0 commit comments

Comments
 (0)