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
5 changes: 5 additions & 0 deletions .changeset/namespace-retirement-now-cited.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/spec': patch
---

The `namespace` rejection messages now cite the decision record that actually exists: ADR-0129 D3 (the enforced object-naming contract — `name` is the canonical id, module prefix embedded literally, no separate namespace declaration). The object tombstone's citation, removed when its previous spelling pointed at a decision letter no ADR declared, comes back pointing at the real record; the translation-contract rejection re-points the same way. The retirement itself is unchanged and still enforced.
2 changes: 1 addition & 1 deletion docs/adr/0028-metadata-naming-and-namespace-isolation.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

**Status**: Deferred (2026-07-16; proposed 2026-06-01) — the target model (namespace as identity dimension, short authored names, derived physical names, `namingMode` dual-read, `sys` reservation, namespaced transport segments) is entirely unbuilt, and adopting it now would be a platform-wide migration (physical table derivation, transport routes, dual-read compatibility) with no present pain to justify it: the literal-prefix current-state contract (`spec/kernel/namespace-prefix.ts`) is enforced, collision-proof (ADR-0028/0048), and working. **Re-open trigger**: real multi-package ecosystem naming pain — e.g. a marketplace package whose natural short names are unusable under literal prefixing, or cross-environment portability requiring namespace remapping. Until then this stays a recorded design, not planned work.
**Deciders**: ObjectStack Protocol Architects
**Supersedes**: the hand-written object-namespace-prefix authoring rule documented in `packages/spec/src/kernel/manifest.zod.ts` (the `namespace` field) and enforced by `validateNamespacePrefix()` in `packages/spec/src/stack.zod.ts` — there is no standalone ADR for that rule today.
**Supersedes**: the hand-written object-namespace-prefix authoring rule documented in `packages/spec/src/kernel/manifest.zod.ts` (the `namespace` field) and enforced by `validateNamespacePrefix()` in `packages/spec/src/stack.zod.ts` — that rule's standalone record is [ADR-0129](./0129-object-name-is-the-canonical-id.md) (2026-08-29; it governs until this ADR's re-open trigger fires).
**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source per type, org overlay), [ADR-0008](./0008-metadata-repository-and-change-log.md) (Repository · ChangeLog · Cache · Registry; `MetaRef = org/type/name`), [ADR-0010](./0010-metadata-protection-model.md) (protection model), [ADR-0019](./0019-app-as-consumer-unit.md) (app as the consumer-installable unit), [ADR-0025](./0025-plugin-package-distribution.md) (package distribution), [ADR-0029](./0029-kernel-object-ownership-and-platform-objects-decomposition.md) (**prerequisite** — kernel object ownership; D5/D6 below assume the kernel is properly owned per ADR-0029)
**Consumers**: `@objectstack/spec` (manifest + stack validators), `@objectstack/objectql` (`SchemaRegistry`, `StorageNameMapping`, ownership model), `@objectstack/plugins/driver-sql` (physical table derivation), `@objectstack/rest` + `@objectstack/api` (route + generated-surface naming), `@objectstack/services/service-automation` (connector registry), `@objectstack/services/service-ai` (tool registry), `@objectstack/platform-objects` (kernel object ownership), `@objectstack/cli` (`os validate`)

Expand Down
14 changes: 7 additions & 7 deletions docs/adr/0067-commit-history-and-rollback-for-ai-authoring.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# ADR-0067: Commit history and rollback for AI authoring — turns become atomic, revertible commits

**Status**: Accepted (2026-06-24; completed 2026-07-16) · **Amended** (2026-08-06, #5351/#5696 — the D2 join is now distinguishable (`owned`), and Decision-2's atomicity is enforced as a ONE-datasource promise, with audit rows carved out and possibly orphaned; see the Amendment at the end) — fully implemented: commit grouping (`sys_metadata_commit`), `revertCommit`/`rollbackToPackageCommit`/`listCommits`, REST routes; **Decision-2 landed via #3066**: `publishPackageDrafts` runs every promotion + the commit record inside ONE `engine.transaction()` (two-phase — side effects post-commit), so a commit cannot half-land; `engine.transaction()` joins ambient transactions to make nested repository writes participate. Locked by `protocol-publish-package-drafts.test.ts` (all-or-nothing + rollback tracking) and `engine-ambient-transaction.test.ts`.
**Status**: Accepted (2026-06-24; completed 2026-07-16) · **Amended** (2026-08-06, #5351/#5696 — the D2 join is now distinguishable (`owned`), and Decision-2's atomicity is enforced as a ONE-datasource promise, with audit rows carved out and possibly orphaned; see the Amendment at the end) — fully implemented: commit grouping (`sys_metadata_commit`), `revertCommit`/`rollbackToPackageCommit`/`listCommits`, REST routes; **Decision-2 landed via #3066**: `publishPackageDrafts` runs every promotion + the commit record inside ONE `engine.transaction()` (two-phase — side effects post-commit), so a commit cannot half-land; `engine.transaction()` joins ambient transactions to make nested repository writes participate. Locked by `protocol-publish-package-drafts.test.ts` (all-or-nothing + rollback tracking) and `engine-ambient-transaction.test.ts`. **Letter index** (2026-08-29, #12786): the Decision headings are relabeled `1.`–`6.` → `D1`–`D6` so `Dk` citations verify — recognition of the record's own numbering (this status line and the Amendment already say "D2"); no decision is renumbered, reordered, or rewritten.
**Deciders**: ObjectStack Protocol Architects
**Builds on / amends**: [ADR-0045](./0045-additive-materialization-and-visibility-gate.md) (**amended**: ADR-0045 keeps a *draft + human-confirm* gate on mutations as the safety mechanism; this ADR replaces *confirm-before* with *revert-after* for everything except irreversible data loss, and unifies the two authoring regimes under one primitive — the commit), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (draft workspace — retained as a *review affordance*, demoted from *safety mechanism*), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) ("AI never publishes — it drafts" → **AI commits; commits are revertible**), [ADR-0034](./0034-transactional-writes-and-ambient-transaction.md) (per-write transaction — **extended to span a whole turn**), [ADR-0038](./0038-build-verification-loop.md) (machine gate — runs per commit, before it lands)
**Consumers**: `@objectstack/objectql` (commit grouping, atomic turn-apply, `revertCommit`, history query — built on the existing `sys_metadata_history` + `restoreVersion`), `@objectstack/runtime` + `@objectstack/rest` (commit/revert routes), `../cloud/service-ai-studio` (turn = commit; auto-commit policy; data-loss confirmation), `../objectui` (commit timeline + "revert to here")
Expand DownExpand Up@@ -64,27 +64,27 @@ ADR-0045's own "Consequences/Costs" lists the two regimes ("materialize vs. draf

## Decision

### 1. A turn is a commit
### D1 — A turn is a commit

A **commit** is the atomic set of metadata events produced by one apply/turn, identified by a `commit_id` and recorded as a group over `sys_metadata_history`. Minimal mechanism: a `commit_id` column on the history event (plus a thin `sys_metadata_commit` row carrying `{ commit_id, package_id, organization_id, message, actor, ai_model?, event_seq_range, parent_commit_id }`). No new snapshot store — the bodies are already in `sys_metadata_history.metadata`.

The commit's **message is the user's prompt**; its **actor** is the AI principal; its **diff** is computed from the per-event `checksum` / `previous_checksum` already recorded. A package's commits form a **linear history with a HEAD** (the current top), scoped per environment (metadata is org/env-scoped).

The server owns commit assembly — the model never constructs it. `apply_blueprint` and the per-item authoring tools (`add_field`, `create_metadata`, …) run inside a turn context that opens a commit, writes through it, and seals it.

### 2. Commits are atomic
### D2 — Commits are atomic

All metadata writes in a turn execute inside **one `engine.transaction()`**; any failure rolls the whole commit back — no half-built app, no orphan rows, no partial visibility flip. This replaces `publishPackageDrafts`'s per-item best-effort loop with a turn-spanning transaction (the per-item `withTxn` composes into it). The visibility flip (ADR-0045 `hidden:false`) is part of the same transaction — never "some apps visible, some not."

*Driver caveat*: in-memory drivers have no real transaction (`withTxn` no-ops); atomicity is a SQL-driver guarantee. Production is SQL; document the gap, don't paper over it.

### 3. Rollback is first-class and append-only
### D3 — Rollback is first-class and append-only

- `revertCommit(commitId)` — restore every item the commit touched to its **pre-commit** body, atomically (reuse `restoreVersion` per item inside one transaction). Recorded as a **new forward commit** (`operation_type='revert'`), so history is never rewritten and a revert is itself revertible.
- `rollbackToPackageCommit(commitId)` — revert the package's HEAD back through every commit after the target, as one transaction. This is the "一层一层回退" the founder described — **commit-granular, not per-artifact** (§Resolved-1).
- Revert restores **metadata** deterministically. Its effect on **data** is governed by §5.

### 4. The confirm gate, relocated
### D4 — The confirm gate, relocated

ADR-0045 gated *publish* (a human confirms before anything visible changes). This ADR relocates the gate to the only place revert can't save you:

Expand All@@ -93,7 +93,7 @@ ADR-0045 gated *publish* (a human confirms before anything visible changes). Thi

The ADR-0027 **draft workspace + `?preview=draft` diff review** is retained as a *review affordance* (a reviewer may still preview a pending change), but it is **no longer the safety mechanism** — revertibility is. Drafts stop being mandatory for mutations in the default path.

### 5. Metadata reverts cleanly; data is made reversible (the crux)
### D5 — Metadata reverts cleanly; data is made reversible (the crux)

Metadata is declarative and snapshotted, so it reverts exactly. Data does not: reverting "create object *X*" or "drop field *f*" touches real tables and real rows — including rows a user typed in after publish. The rule that keeps revert safe:

Expand All@@ -103,7 +103,7 @@ Metadata is declarative and snapshotted, so it reverts exactly. Data does not: r
- the new object holds **user-entered rows beyond the seed** → revert requires a **typed confirmation that names the impact** ("revert will remove object *X* and its **N** rows, **M** of them entered after publish") **and auto-cuts a `sys_package_version` named restore point first** (the escape hatch — the revert is itself undoable).
- This is the precise boundary of the §4 confirm gate: a human confirms **iff** the operation would destroy real user data that revert cannot resurrect.

### 6. Substrate and restore points
### D6 — Substrate and restore points

- **Commit log = `sys_metadata_history`** (framework, per-event, reuse). This is the per-turn substrate. No full-bundle copy per turn.
- **`sys_package_version` = named restore points** (cloud, full-bundle, heavy), cut only on: the §5 pre-destructive-revert escape hatch; a user-initiated "name a checkpoint" ("before I let the AI go wide"); and marketplace release. *Not* per turn.
Expand Down
112 changes: 112 additions & 0 deletions docs/adr/0129-object-name-is-the-canonical-id.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
# ADR-0129: Object `name` is the canonical id — literal module prefix, no separate `namespace` field

**Status**: Accepted (2026-08-29; maintainer ruling on #12918, option 甲 — recorded as one
convoy with #12786). This ADR **records an already-enforced, platform-wide decision**; it
changes no shipped behavior. The contract below has been enforced since the parse path was
closed by #4001 and is restated by Prime Directive #6 in `AGENTS.md` — what was missing was
the decision record itself, and that absence is what produced the phantom citation this ADR
closes (see § History).
**Deciders**: ObjectStack maintainer (2026-08-29), recording a contract in force since #4001.
**Relates to**: [ADR-0028](./0028-metadata-naming-and-namespace-isolation.md) (the **Deferred
target model** pointing the opposite way — reconciled in D4 below),
[ADR-0048](./0048-cross-package-metadata-collision.md) (cross-package collision-proofing of
the literal prefix), ADR-0006 (the record the phantom letter was mis-attributed to — the
project/environment split, which never decided object naming), #4001 (parse-path closure),
#4522 (tombstone), #12913 / #12917 / #12918 (the 2026-08-28 incident and its remediation).
**Consumers**: `@objectstack/spec` (`data/object.zod.ts`, `stack.zod.ts`,
`kernel/namespace-prefix.ts`, `system/translation.zod.ts`), `@objectstack/objectql`
(`SchemaRegistry`, `StorageNameMapping`), every SQL driver, `@objectstack/rest`, the client
SDKs, and every metadata author — human or AI.

---

## TL;DR

The object `name` is the **only** identity an object has, everywhere. The module prefix is
part of the name the author writes (`sys_user`, `crm_account`), validated against the
package's declared namespace. There is **no** separate `namespace` field on the object
document — the key is retired and **rejected on the parse path** with a prescription. This
page exists because the decision was enforced for months with no ADR recording it, and an
unwritten decision manufactures phantom citations (§ History).

---

## Decision

### D1 — The object `name` IS the canonical id everywhere

One string is the object's identity across every surface: the API, ObjectQL, REST routes,
the client SDKs, and the **database table name**. There is no logical/physical split and no
parallel identity dimension: `StorageNameMapping.resolveTableName` is a pass-through
(`packages/spec/src/system/constants/system-names.ts`; every SQL driver routes through it),
and the object document carries no `tableName` field — the table name always equals `name`
(pinned by `packages/spec/src/data/object.test.ts`, "name-as-identity").

### D2 — The module prefix is embedded literally in the authored name

Authors write the full literal name — `sys_user`, `crm_account` — never a short name plus a
namespace. A package declares its prefix once (`manifest.namespace`), and every object it
defines MUST be named `${namespace}_${shortName}` (platform-reserved `sys_*` names exempt).
Enforced at **both** chokepoints from one shared rule
(`packages/spec/src/kernel/namespace-prefix.ts` — the single source, so the two cannot
drift):

- compile time — `validateNamespacePrefix()` in `packages/spec/src/stack.zod.ts`
(`defineStack()` / `os validate`);
- runtime — `MetadataManager.publishPackage()` (Studio publish).

The literal prefix is collision-proof across installed packages (ADR-0048) and gives the AI
author exactly one writing style — the property the rule exists for.

### D3 — There is no separate `namespace` field; the key is retired and rejected loudly

The object document has **no** `namespace` key. It is retired with a tombstone: the strict
`ObjectSchema` rejects the key on parse, and the rejection message carries the fix
(`UNKNOWN_KEY_GUIDANCE.namespace` in `packages/spec/src/data/object.zod.ts` —
`namespace: "sys", name: "user"` becomes `name: "sys_user"`). Until #4001 closed the parse
path the key was **stripped in silence**, so an object declaring one shipped under the
unprefixed name its author never intended — the rejection is the fix for that failure mode,
not a style preference. The retirement is platform-wide: the translation contract rejects
the key the same way (`packages/spec/src/system/translation.zod.ts`), and Prime Directive #6
in `AGENTS.md` states the authoring rule for every agent working this repo.

### D4 — Reconciliation with ADR-0028: this is the governing record until 0028's re-open trigger fires

[ADR-0028](./0028-metadata-naming-and-namespace-isolation.md) records the **opposite**
model — namespace as an identity dimension, short authored names, derived physical names
(its D1–D6). That model is a **Deferred target design, entirely unbuilt**, by 0028's own
status line, which also records the literal-prefix current state as "enforced,
collision-proof (ADR-0028/0048), and working" and names a re-open trigger (real
multi-package ecosystem naming pain). The two records do not conflict: 0028 is a deferred
design, this ADR is the enforced present. **Until 0028's recorded re-open trigger fires,
this ADR is the governing record of object naming.** If 0028 is ever adopted, the adopting
work must supersede this record explicitly (Prime Directive #13) — never drift past it.

---

## History — why this record exists

The contract above was enforced with **no ADR recording it**. When #4522 added the
tombstone's rejection message, its author needed a provenance for a real, enforced decision,
found none, and minted one: a phantom decision letter — spelled "D4" — attributed to
ADR-0006, a record that never decided object naming and declares no such letter. The
citation survived for months because the ADR-0006 family declared no letters at all, so
`check:adr-anchors` had nothing to contradict — until #12736 minted D1–D3 for ADR-0006's
API-surface addendum (2026-08-28), at which point the phantom became mechanically
unresolvable and every merge-queue build went red (#12913; emergency one-string fix
#12917). #12918 recorded the root cause: **the decision was real, enforced, and unwritten**
— and the gate's own remedy text says what to do about that: "if the decision is real but
unwritten, it needs an ADR, not a citation." This is that ADR. The surviving spellings
re-point here (the letter to cite for the retirement is **D3**); `packages/spec/CHANGELOG.md`
keeps its historical copies untouched.

## Consequences

- The retirement finally has a citable record: rejection messages, tests, and code comments
cite **ADR-0129 D3** instead of a phantom, and `check:adr-anchors` can verify them.
- The next author (or AI) who asks "why can't `namespace` come back?" finds a decision, not
an enforcement with no paper trail — the failure mode that produced the phantom does not
re-arm.
- ADR-0028's Supersedes-line note ("there is no standalone ADR for that rule today") is
corrected by this record's existence; nothing else in 0028 changes, and its Deferred
status and re-open trigger stand exactly as written.
8 changes: 5 additions & 3 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3404,7 +3404,8 @@ export class ObjectQL implements IObjectQLEngine {
// Propagate the service-principal label (`ExecutionContext.actor`,
// e.g. `svc:flow:<name>`) so a non-user write stays attributable in the
// audit log — the writer's `userId ?? session.actor` fallback is dead
// without this hop (ADR-0014 D2, #4366).
// without this hop (the `ExecutionContext.actor` audit-attribution
// contract, `@objectstack/spec` kernel/execution-context.zod.ts; #4366).
...(typeof (execCtx as any).actor === 'string' && (execCtx as any).actor
? { actor: (execCtx as any).actor }
: {}),
Expand DownExpand Up@@ -11166,8 +11167,9 @@ export class ObjectQL implements IObjectQLEngine {
): void {
// The triggered-by half. `userId` survives the `sudo()`-shaped elevation,
// which is what makes the two halves recordable at all; `actor` is the
// service-principal channel (ADR-0014 D2) that keeps a non-user-
// authenticated caller attributable instead of anonymous.
// service-principal channel (the `ExecutionContext.actor` contract,
// `@objectstack/spec` kernel/execution-context.zod.ts) that keeps a
// non-user-authenticated caller attributable instead of anonymous.
const triggeredBy =
(context?.userId != null && String(context.userId)) ||
(typeof (context as any)?.actor === 'string' && (context as any).actor.trim()) ||
Expand Down
Loading
Loading