Skip to content
16 changes: 16 additions & 0 deletions .changeset/29373-export-withheld-maps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': minor
---

feat(spec): the package export manifest declares `withheldMaps`, the maps it withheld whole, and an unmanaged import accepts it

Clause-②: yes (widening)
Metadata-protocol: added

`GET /packages/:id/export` writes each item as a served read returns it, so a connector's header and environment-variable maps are already withheld whole by the connector redactor and the exported item simply lacks them. An import elsewhere could not tell such a map from one the package never had. The export contract now has a place to say so:

- **`PackageExportManifestSchema` gains an optional fixed key `withheldMaps`**, a list of `{ type, name, path }`: `type` is `connector`, `name` is the connector item's name, and `path` is one of the four positions the redactor withholds whole, spelled as it reports them: `providerConfig.headers`, `providerConfig.defaultHeaders`, `providerConfig.transport.headers`, `providerConfig.transport.env`. Positions only: no header or variable name is listed, and an entry carrying any other key is refused. It is declared ahead of its producer: no export writes it and no install door reads it yet, and its describe opens `[PLANNED — not yet served]` until the runtime half serves it. Until then, an export's lack of the key says nothing about what was withheld.
- **`UNMANAGED_INSTALL_KEY_DISPOSITIONS` classifies `withheldMaps` as `door`**, so an unmanaged import accepts an export that carries it instead of refusing the whole package for an unrecognised key. The runtime half has the door read it to name each incomplete map; the new base never carries it.
- **The export's `label` is classified `door` too.** The export contract has always declared it, but the table never classified it, so an unmanaged import refused any export that carried one. The base's install manifest declares no `label`, so the base is named by the request's `targetName` or the source `name`, as before.

The table's totality test now reads the export's fixed keys as a third schema a package body is read with, beside `ManifestSchema` and the stack definition, so a fixed key the export gains reddens a spec test on the PR that adds it.
19 changes: 18 additions & 1 deletion content/docs/references/api/package-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ declared a VERSION rollback against the live COMMIT-rollback path;
`RollbackToPackageCommitResponseSchema` below is the true contract,
authored after that retirement per the ruling's sequencing (3A).

Two members are not transcriptions but DECLARED AHEAD OF THEIR PRODUCER
(ADR-0049), each describe opening `[PLANNED — not yet served]` until the
change that serves it drops the marker: the `nav` arm of
`GetPackageCustomizationsResponseSchema.contributions` (#27928) and
`PackageExportManifestSchema.withheldMaps` (#28640 ruling ③A, served by
#29342). Their own docblocks say what is not served yet.

<Callout type="info">
**Source:** `packages/spec/src/api/package-lifecycle.zod.ts`
</Callout>
Expand Down Expand Up @@ -316,7 +323,7 @@ A field or flow another package contributes to one of the base's objects.

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **packages** | `({ id: string; name: string; version: string; label?: string } & Record<string, any>)[]` | ✅ | Every package whose registry row's `source` is not `code` (the marketplace and authored packages, a `.custom` pair among them), enabled or not, each once and each as `GET /packages/:id/export` answers it, dependencies first: an entry comes after every entry its package's `manifest.dependencies` names, so a `.custom` pair comes after its base. Install the entries in this order; never re-sort them. No entry carries an enabled state, so every entry installs enabled. Empty when every package of the deployment is registered by its own code. |
| **packages** | `({ id: string; name: string; version: string; label?: string; … } & Record<string, any>)[]` | ✅ | Every package whose registry row's `source` is not `code` (the marketplace and authored packages, a `.custom` pair among them), enabled or not, each once and each as `GET /packages/:id/export` answers it, dependencies first: an entry comes after every entry its package's `manifest.dependencies` names, so a `.custom` pair comes after its base. Install the entries in this order; never re-sort them. No entry carries an enabled state, so every entry installs enabled. Empty when every package of the deployment is registered by its own code. |

### Nested Shape: `PackageExportBundle.packages[number]`

Expand All @@ -326,6 +333,7 @@ A field or flow another package contributes to one of the base's objects.
| **name** | `string` | ✅ | The exported package's machine name. |
| **version** | `string` | ✅ | The exported package's version. |
| **label** | `string` | optional | Display label, when the package declares one. |
| **withheldMaps** | `{ type: 'connector'; name: string; path: Enum<'providerConfig.headers' \| 'providerConfig.defaultHeaders' \| …> }[]` | optional | [PLANNED — not yet served] Declared ahead of its producer (ADR-0049): no export writes this list yet and no install door reads it yet. Every map the export withheld WHOLE because a served read never carries it: a connector's header and environment-variable maps. The item is written with that map absent, so once served an import reads this list to tell an incomplete map from one the package never had, and names each one for an operator to re-enter. Positions only: no header or variable name is listed. Absence says nothing about what was withheld when the exporter predates this key; from an exporter that writes it, absence means nothing was withheld. |


---
Expand All @@ -340,6 +348,15 @@ A field or flow another package contributes to one of the base's objects.
| **name** | `string` | ✅ | The exported package's machine name. |
| **version** | `string` | ✅ | The exported package's version. |
| **label** | `string` | optional | Display label, when the package declares one. |
| **withheldMaps** | `{ type: 'connector'; name: string; path: Enum<'providerConfig.headers' \| 'providerConfig.defaultHeaders' \| …> }[]` | optional | [PLANNED — not yet served] Declared ahead of its producer (ADR-0049): no export writes this list yet and no install door reads it yet. Every map the export withheld WHOLE because a served read never carries it: a connector's header and environment-variable maps. The item is written with that map absent, so once served an import reads this list to tell an incomplete map from one the package never had, and names each one for an operator to re-enter. Positions only: no header or variable name is listed. Absence says nothing about what was withheld when the exporter predates this key; from an exporter that writes it, absence means nothing was withheld. |

### Nested Shape: `PackageExportManifest.withheldMaps[number]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `'connector'` | ✅ | Metadata type of the item whose map was withheld. |
| **name** | `string` | ✅ | Name of the item whose map was withheld. |
| **path** | `Enum<'providerConfig.headers' \| 'providerConfig.defaultHeaders' \| …>` | ✅ | Dot path of the withheld map inside the item. |


---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `api/` | 489 |
| `api/` | 490 |
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,7 @@
"api/PackageExportManifest:label",
"api/PackageExportManifest:name",
"api/PackageExportManifest:version",
"api/PackageExportManifest:withheldMaps",
"api/PackageInstallManifest:capabilities [RETIRED]",
"api/PackageInstallManifest:configuration [RETIRED]",
"api/PackageInstallManifest:contributes",
Expand Down
56 changes: 54 additions & 2 deletions packages/spec/src/api/package-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
GetPackageCustomizationsResponseSchema,
} from './package-lifecycle.zod';
import { PackagePublishResultSchema as SystemPackagePublishResultSchema } from '../system/metadata-persistence.zod';
import { CONNECTOR_CREDENTIAL_MAP_PATHS, redactConnectorItem } from '../integration/connector-credential-redaction';
import { InstalledPackageSourceSchema } from '../kernel/package-registry.zod';

describe('the ruled re-export of PackagePublishResultSchema into the `/api` namespace', () => {
Expand Down Expand Up @@ -197,7 +198,7 @@ describe('RollbackToPackageCommitResponseSchema declares the COMMIT-rollback bod
});
});

describe('PackageExportManifestSchema declares the four fixed keys and stays open', () => {
describe('PackageExportManifestSchema declares the five fixed keys and stays open', () => {
/** A verbatim-shaped capture of a real `assemblePackageManifest` return. */
const realResponse = {
id: 'com.example.crm',
Expand All @@ -214,7 +215,7 @@ describe('PackageExportManifestSchema declares the four fixed keys and stays ope
if (result.success) expect(result.data).toEqual(realResponse);
});

it('requires exactly the four fixed keys — a manifest with no items still parses', () => {
it('requires only the three identity keys — a manifest with no items still parses', () => {
const bare = { id: 'com.example.empty', name: 'empty', version: '1.0.0' };
expect(PackageExportManifestSchema.safeParse(bare).success).toBe(true);
});
Expand All @@ -224,6 +225,57 @@ describe('PackageExportManifestSchema declares the four fixed keys and stays ope
});
});

describe('PackageExportManifestSchema.withheldMaps names each map the export withheld whole (#28640 ruling ③A)', () => {
const identity = { id: 'com.example.crm', name: 'example-crm', version: '2.1.0' };
/** The connector item as the export writes it: served, so its header map is already absent. */
const servedConnector = { name: 'stripe', label: 'Stripe', type: 'api', provider: 'rest', providerConfig: { baseUrl: 'https://api.stripe.example' } };

it('parses an export that lists a withheld map and PRESERVES it — the item body stays as served', () => {
const manifest = {
...identity,
connectors: [servedConnector],
withheldMaps: [{ type: 'connector', name: 'stripe', path: 'providerConfig.headers' }],
};
const result = PackageExportManifestSchema.safeParse(manifest);
expect(result.success).toBe(true);
if (result.success) expect(result.data).toEqual(manifest);
});

it('is declared ahead of its producer: the describe opens with the planned marker and names the ADR, never a tracker id', () => {
const description = PackageExportManifestSchema.shape.withheldMaps.description ?? '';
expect(description).toMatch(/^\[PLANNED — not yet served\] /);
expect(description).toContain('ADR-0049');
expect(description).not.toMatch(/#\d{3,}/);
});

it('is optional — an export that withheld nothing, or one from before the key, still parses', () => {
expect(PackageExportManifestSchema.safeParse(identity).success).toBe(true);
});

it('spells each position exactly as the connector redactor reports it — the same four maps, nothing else', () => {
const declared = [...PackageExportManifestSchema.shape.withheldMaps.unwrap().element.shape.path.options];
// The redactor's own list of the maps it withholds whole …
expect(declared).toEqual(CONNECTOR_CREDENTIAL_MAP_PATHS.map((p) => p.join('.')));
// … and the spelling its `redactedKeys` names them by, read off a real redaction.
const map = { Authorization: 'x' };
const { redactedKeys } = redactConnectorItem({
...servedConnector,
providerConfig: { headers: map, defaultHeaders: map, transport: { headers: map, env: { TOKEN: 'x' } } },
});
expect([...redactedKeys].sort()).toEqual([...declared].sort());
});

it.each([
['a position outside the four', { type: 'connector', name: 'stripe', path: 'auth.token' }],
['a position spelled without its providerConfig root', { type: 'connector', name: 'stripe', path: 'defaultHeaders' }],
['a type no export withholds a map of yet', { type: 'webhook', name: 'hook', path: 'providerConfig.headers' }],
['a list of the withheld header names — maps are withheld whole', { type: 'connector', name: 'stripe', path: 'providerConfig.headers', keys: ['Authorization'] }],
['an entry with no item name', { type: 'connector', path: 'providerConfig.headers' }],
])('refuses %s', (_label, entry) => {
expect(PackageExportManifestSchema.safeParse({ ...identity, withheldMaps: [entry] }).success).toBe(false);
});
});

describe('PackageExportBundleSchema declares the whole-deployment export as composed per-package exports (#28914)', () => {
/**
* The bundle the card's done criteria name: a base, its `.custom` pair and a
Expand Down
49 changes: 48 additions & 1 deletion packages/spec/src/api/package-lifecycle.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@
* declared a VERSION rollback against the live COMMIT-rollback path;
* `RollbackToPackageCommitResponseSchema` below is the true contract,
* authored after that retirement per the ruling's sequencing (3A).
*
* Two members are not transcriptions but DECLARED AHEAD OF THEIR PRODUCER
* (ADR-0049), each describe opening `[PLANNED — not yet served]` until the
* change that serves it drops the marker: the `nav` arm of
* `GetPackageCustomizationsResponseSchema.contributions` (#27928) and
* `PackageExportManifestSchema.withheldMaps` (#28640 ruling ③A, served by
* #29342). Their own docblocks say what is not served yet.
*/

import { z } from 'zod';
Expand Down Expand Up @@ -175,16 +182,56 @@ export const RollbackToPackageCommitResponseSchema = lazySchema(() => z.object({
* (`runtime/src/domains/packages.ts`) builds the key set DYNAMICALLY from the
* metadata type registry — one plural key per type present
* (`manifest[plural] = items.map(clean)`), partitioning `views` per #5320 —
* plus the four fixed keys below. Only the fixed keys are pinnable;
* plus the five fixed keys below. Only the fixed keys are pinnable;
* enumerating the registry here would freeze this contract against future
* metadata types, so everything else deliberately falls to the open
* catch-all. Freezes nothing.
*
* `withheldMaps` (#28640 ruling ③A) names each map position the export
* withheld whole. The export reads SERVED items, so the connector redactor
* (`CONNECTOR_CREDENTIAL_MAP_PATHS`, `integration/connector-credential-redaction.ts`)
* has already dropped each of its four header / env maps, and the item is
* written with the map simply absent; this list is how an import tells an
* incomplete map from one that was never written. Positions only — never a
* header or variable name — so the credential heuristic stays the
* redactor's, not a contract of the export. The path vocabulary is the
* redactor's own `redactedKeys` spelling of those four positions, and
* `package-lifecycle.test.ts` holds the two equal.
*
* DECLARED AHEAD OF ITS PRODUCER (ADR-0049): `withheldMaps` is declared
* before any export writes it and before any install door reads it. Until the
* server half (#29342) makes `assemblePackageManifest` write the list and both
* install doors name each incomplete map, no export carries it; its describe
* opens `[PLANNED — not yet served]`, and #29342 drops the marker in the change
* that serves it (the pin is in `package-lifecycle.test.ts`). Its `door` row in
* `UNMANAGED_INSTALL_KEY_DISPOSITIONS` is live today: it keeps the unmanaged
* import from refusing an export that carries the key.
*/
export const PackageExportManifestSchema = lazySchema(() => z.object({
id: z.string().describe('The exported package\'s id.'),
name: z.string().describe('The exported package\'s machine name.'),
version: z.string().describe('The exported package\'s version.'),
label: z.string().optional().describe('Display label, when the package declares one.'),
// Declared ahead of its producer: #29342 writes and reads it, and drops the
// `[PLANNED — not yet served]` marker in that change.
withheldMaps: z.array(z.object({
type: z.literal('connector').describe('Metadata type of the item whose map was withheld.'),
name: z.string().describe('Name of the item whose map was withheld.'),
path: z.enum([
'providerConfig.headers',
'providerConfig.defaultHeaders',
'providerConfig.transport.headers',
'providerConfig.transport.env',
]).describe('Dot path of the withheld map inside the item.'),
}).strict()).optional().describe(
'[PLANNED — not yet served] Declared ahead of its producer (ADR-0049): no export writes this list '
+ 'yet and no install door reads it yet. Every map the export withheld WHOLE because a served read '
+ 'never carries it: a connector\'s header and environment-variable maps. The item is written with '
+ 'that map absent, so once served an import reads this list to tell an incomplete map from one the '
+ 'package never had, and names each one for an operator to re-enter. Positions only: no header or '
+ 'variable name is listed. Absence says nothing about what was withheld when the exporter predates '
+ 'this key; from an exporter that writes it, absence means nothing was withheld.',
),
}).catchall(z.unknown().describe(
'One key per metadata type present in the package (plural spelling, e.g. '
+ '`objects`, `views`), each an array of cleaned item bodies. The key set '
Expand Down
Loading
Loading