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
24 changes: 24 additions & 0 deletions .changeset/25792-pre-install-dependency-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/metadata-core': patch
'@objectstack/metadata-protocol': patch
'@objectstack/runtime': patch
'@objectstack/cloud-connection': patch
'@objectstack/spec': patch
---

feat(install): installing a package checks its declared `dependencies` first — every entry must name a package installed at a version inside its range, or one the same artifact delivers, or the install is refused `422 DEPENDENCY_MISSING` (ADR-0141 D2)

Clause-②: yes (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is added, removed, renamed or re-shaped: `manifest.dependencies` keeps its record shape and meaning, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What changes is the answer the three install doors give a package whose declared dependencies the target environment does not satisfy. -->

**BREAKING** (an install-acceptance narrowing) for a package whose manifest declares a `dependencies` entry the target environment does not satisfy. The three install doors — `POST /api/v1/packages` (both its protocol arm and its no-protocol fallback), the protocol's `installPackage` called in-process, and `POST /api/v1/marketplace/install-local` — used to install such a package and let the gap surface at its first cross-app save. They now refuse the whole install, before anything is registered, written or synced, with `422 DEPENDENCY_MISSING`.

An entry `(id, range)` is satisfied when `id` is installed at a `manifest.version` inside `range`, or is delivered by the same artifact (presence only: the packages of one artifact are one tested unit). Ranges are read at exact-version granularity in npm style: `^`, `~`, `>=`, `>`, `<=`, `<`, `=`, an exact version, `x` / `*` wildcards, hyphen ranges, space- or comma-joined intersections and `||` unions. A range outside that grammar (`latest` included) or an installed version that is not `MAJOR.MINOR.PATCH` does not satisfy — the check fails closed.

The refusal names every unmet entry in one answer: `error.details.issues` carries one `AppCompatibilityCheck` issue per entry (`severity: 'error'`, `category: 'dependency_missing'`), and `error.details.unmet` the facts per entry — `packageId`, `dependency`, `range`, `found` (`absent`, `out-of-range`, `unreadable-range`, `unreadable-version` or `unreadable-declaration`) and the `installedVersion` when one is installed.

Unchanged: a package that declares no dependencies installs exactly as before; a boot never runs the check (what an install admitted stays admitted); `PATCH /api/v1/packages/:id` still applies a `dependencies` edit as given.

**What changes for you.** Install a package's dependencies, at versions inside its declared ranges, before the package itself; the refusal says which and at what range. `@objectstack/metadata-core` exports the check (`checkDeclaredDependencies`, `assertDeclaredDependencies`), its error (`DependencyMissingError`, `isDependencyMissingError`, `dependencyMissingAnswer`) and the range reader (`rangeAdmitsVersion`, `readExactVersion`, `DEPENDENCY_RANGE_GRAMMAR`). `DEPENDENCY_MISSING` is registered in the error-code ledger under `@objectstack/metadata-core`.
155 changes: 155 additions & 0 deletions content/docs/deployment/app-dependencies.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
---
title: App dependencies — declare one, and what install checks
navTitle: App Dependencies
description: Declare that an app depends on another installed app in manifest.dependencies, the version-range grammar install reads, what the DEPENDENCY_MISSING refusal means, and what build-time validation cannot see.
---

An installable app may depend on another installed app and reference its
objects ([ADR-0141](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0141-inter-app-dependency-declared-checked-before-install.md)).
Nothing new is authored for it: the declaration is the `dependencies` key every
manifest already has, and installing the app checks it once.

## Declare a dependency

`dependencies` maps the depended-on package's id to a version range:

```ts
import { defineStack } from '@objectstack/spec';

export default defineStack({
manifest: {
id: 'com.acme.psa',
name: 'PSA',
version: '1.0.0',
type: 'app',
namespace: 'psa',
dependencies: {
'com.acme.crm': '6.x', // "install CRM 6.x first"
},
},
// …
});
```

The declaration does two things:

- **It opens the reference.** The runtime save door judges a list view against
your package's own objects and those of the packages your manifest declares,
transitively; a view bound to an object outside that set is refused there,
`422`, and the fix it prints is this declaration. A dependency opens the
reference, not the data: users still see the other app's records under that
app's own permission sets and sharing rules.
- **It is checked before install** — below.

Every entry is required; there is no optional arm. Declare only the packages
your app actually references.

## The version-range grammar

Install reads each range at exact-version granularity, npm-style:

| You write | It admits |
|:---|:---|
| `6.x`, `6.*`, `6` | any `6.y.z` |
| `6.1.x`, `6.1` | any `6.1.z` |
| `^6.2.0` | `>=6.2.0 <7.0.0` (`^0.2.3` is `>=0.2.3 <0.3.0`) |
| `~6.2.0` | `>=6.2.0 <6.3.0` |
| `>=6.2.0`, `>6.2.0`, `<=6`, `<7` | the single bound (`<=6` is `<7.0.0`; `>6` is `>=7.0.0`) |
| `6.2.0`, `=6.2.0` | exactly `6.2.0` |
| `6.1.0 - 6.3` | `>=6.1.0 <6.4.0` |
| `>=6.2.0 <7` (or `>=6.2.0, <7`) | both bounds — an intersection |
| `^5.4.0 \|\| ^6.1.0` | either alternative |
| `*`, `x` | any version |

Anything else — `latest` or another dist-tag, a malformed version — is **not
readable, and an unreadable range never satisfies**: the install is refused
naming the range. The installed side is read as `MAJOR.MINOR.PATCH` (a leading
`v` and `+build` metadata are accepted); a prerelease version such as
`6.1.0-beta.1` is outside the grammar, and a range is never judged satisfied by
one.

## What install checks

When you install a package — `POST /api/v1/packages`, `os package install`
through `POST /api/v1/marketplace/install-local`, or an in-process call to the
protocol's `installPackage` — every entry of **that package's own**
`dependencies` must be one of:

- **installed** in the target environment at a `manifest.version` inside the
declared range; or
- **delivered by the same artifact.** The packages of one release artifact are
one tested unit, so a sibling package the artifact carries counts as present
whatever its version; an in-artifact range mismatch is a build-time concern,
not an install refusal.

Otherwise the whole install is refused before anything is registered, written
or synced:

```json
{
"success": false,
"error": {
"code": "DEPENDENCY_MISSING",
"httpStatus": 422,
"message": "Install refused: a declared dependency is not satisfied in this environment, and nothing was installed. …",
"details": {
"compatible": false,
"issues": [
{ "severity": "error", "category": "dependency_missing", "message": "Package 'com.acme.psa' depends on 'com.acme.crm' 6.x, which is not installed. …" }
],
"unmet": [
{ "packageId": "com.acme.psa", "dependency": "com.acme.crm", "range": "6.x", "found": "absent" }
]
}
}
}
```

Every unmet entry is reported in that one answer. `found` says what was wrong
with each:

| `found` | Meaning | Fix |
|:---|:---|:---|
| `absent` | The dependency is not installed | Install it, at a version inside the range, first |
| `out-of-range` | Installed at `installedVersion`, outside the range | Install a version inside the range, or widen the range and republish |
| `unreadable-range` | The declared range is outside the grammar above | Rewrite the range and republish |
| `unreadable-version` | The installed version is not `MAJOR.MINOR.PATCH` | Install a release version of the dependency |
| `unreadable-declaration` | `dependencies` is not a `{ "PACKAGE_ID": "RANGE" }` record | Rewrite the declaration and republish |

Only the package's own entries are judged: a dependency's dependencies were
judged when it was installed. Whether the dependency is enabled is not judged.

## What install does not check

The check runs at those install acts only. It does not run:

- **at boot.** A restart re-registers what an install already admitted, while
the registry is still filling; it never refuses a package for a dependency it
has not seen yet.
- **at `PATCH /api/v1/packages/:id`.** Editing an authored package's
`dependencies` after install is applied as given; an entry added there is
judged when the package is next installed.
- **at uninstall or upgrade of the dependency.** Removing or upgrading the app
others depend on is not refused today; the refusal at install is the one gate.

## Sharing a namespace with the app you depend on

Each package's namespace must still be its own across artifacts: a package that
uses the namespace of the app it depends on is refused at install with
`NAMESPACE_CONFLICT`, exactly as before. Inside one artifact, packages may share
a namespace ([ADR-0130](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0130-release-artifact-as-co-ownership-boundary.md)).
Dependency-declared namespace co-ownership across artifacts (ADR-0141 D5) is
not delivered yet.

## Build-time validation cannot see a dependency's objects

`os validate`, `os build` and `os lint` judge one stack: they cannot see the
objects of an app your manifest depends on, so a binding to one is neither
admitted nor refused there. The runtime save door is where a cross-app
reference is judged, against the declared dependencies.

## Related

- [Install modes](/docs/deployment/install-modes) — managed or an unmanaged copy
- [Publish and preview](/docs/deployment/publish-and-preview) — how a package version reaches an environment
- [Plugin manifest spec](/docs/protocol/kernel/plugin-spec) — the manifest's keys
1 change: 1 addition & 0 deletions content/docs/deployment/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"single-project-mode",
"publish-and-preview",
"install-modes",
"app-dependencies",
"cloud-connection",
"validating-metadata",
"cli",
Expand Down
26 changes: 15 additions & 11 deletions content/docs/protocol/kernel/plugin-spec.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -468,17 +468,21 @@ Use for large dependencies (React, Vue) that should be shared across plugins.
### Version Constraints

<Callout type="warn">
**The kernel does not check these range strings at plugin load.** `manifest.dependencies`
has exactly two consumers today and both read `Object.keys(...)` only — the ids, never the
ranges: `resolveArtifactPackageOrder` (`packages/core/src/artifact-packages.ts`) hands the
ids to `resolvePluginOrder` as `optionalDependencies`, so an id naming a sibling package in
the same artifact orders ahead of it and an id naming anything else is skipped; and
`resolveWritePackageScope` (`packages/metadata-protocol/src/protocol.ts`) walks the ids
transitively for the write-scope gate. `SemanticVersionManager` and `DependencyResolver`
below are exported from `@objectstack/core` but have no call site outside their own unit
test. So a range here is a **declaration for the installer and for human readers** — a
stale or malformed one fails nothing at load. The forms below are the ones the parser in
`dependency-resolver.ts` accepts, not a set the kernel enforces on you.
**The kernel does not check these range strings at plugin load — installing a package
does.** `manifest.dependencies` has three consumers. Two read `Object.keys(...)` only — the
ids, never the ranges: `resolveArtifactPackageOrder` (`packages/core/src/artifact-packages.ts`)
hands the ids to `resolvePluginOrder` as `optionalDependencies`, so an id naming a sibling
package in the same artifact orders ahead of it and an id naming anything else is skipped;
and `resolveWritePackageScope` (`packages/metadata-protocol/src/protocol.ts`) walks the ids
transitively for the write-scope gate. The third is the pre-install dependency check every
install door runs (ADR-0141 D2): each entry must name a package installed inside its range,
or one the same artifact delivers, or the install is refused `422 DEPENDENCY_MISSING`. That
check reads ranges with `rangeAdmitsVersion` (`packages/metadata-core/src/protocol-handshake.ts`),
whose grammar is on [App dependencies](/docs/deployment/app-dependencies) — not with
`SemanticVersionManager`. `SemanticVersionManager` and `DependencyResolver` below are
exported from `@objectstack/core` but have no call site outside their own unit test; the
forms below are the ones the parser in `dependency-resolver.ts` accepts, not the grammar an
install enforces.
</Callout>

ObjectStack uses **semantic versioning (semver)**. `SemanticVersionManager.satisfies()`
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ const result = AutomationApiErrorCode.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +343 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +344 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |

### Allowed Values: `FlowInnerRefusal.code`
Expand Down Expand Up @@ -463,6 +463,7 @@ const result = AutomationApiErrorCode.parse(data);
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
* `DELIVERY_NEVER_SENT`
* `DELIVERY_NOT_ELIGIBLE`
* `DEPENDENCY_MISSING`
* `DESTRUCTIVE_CHANGE`
* `DEVICE_CODE_FAILED`
* `DOMAIN_VERIFICATION_DISABLED`
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +343 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +344 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
| **message** | `string` | ✅ | Readable error message |
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
Expand Down Expand Up @@ -144,6 +144,7 @@ const result = ApiErrorSchema.parse(data);
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
* `DELIVERY_NEVER_SENT`
* `DELIVERY_NOT_ELIGIBLE`
* `DEPENDENCY_MISSING`
* `DESTRUCTIVE_CHANGE`
* `DEVICE_CODE_FAILED`
* `DOMAIN_VERIFICATION_DISABLED`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ const result = ErrorCode.parse(data);
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
* `DELIVERY_NEVER_SENT`
* `DELIVERY_NOT_ELIGIBLE`
* `DEPENDENCY_MISSING`
* `DESTRUCTIVE_CHANGE`
* `DEVICE_CODE_FAILED`
* `DOMAIN_VERIFICATION_DISABLED`
Expand Down
Loading
Loading