Skip to content
Open
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
63 changes: 49 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,20 @@ instruction to rerun the Factory command in an interactive terminal instead.

### Mounted Notion specs

Notion is an intake source, not a lifecycle writeback surface. Factory reads a
Relayfile-mounted page, binds it to a stable `notion:<page-id>` identity and
digest, then either creates a labeled GitHub issue for a repository target or
dispatches an exact-path workspace task through the fleet. Existing GitHub and
Linear discovery continue unchanged.
Notion is an intake source, not a lifecycle issue source or writeback surface.
Factory reads a Relayfile-mounted page, binds it to a stable
`notion:<page-id>` identity and digest, then either creates a labeled GitHub
issue for a repository target or dispatches an exact-path workspace task
through the fleet. The config field `issueSource` continues to select the
discovery and lifecycle-writeback adapter (`linear` or `github`); Notion intake
normalizes into that lifecycle instead of becoming a third adapter.

The absence of Notion writeback is deliberate. Relayfile supplies the mounted
page as a read-only execution contract, and Factory has no guarded Notion
property/comment writeback adapter or operator-defined lifecycle mapping.
Repository targets reconcile accepted, dispatched, PR, blocked, and completed
state on their generated GitHub issue. Native Notion lifecycle reconciliation
requires a separate provider capability and product contract.

New pages fail closed unless the first block is an explicit ready spec:

Expand Down Expand Up @@ -206,15 +215,35 @@ hatch is deliberately page-specific; there is no title or content heuristic.
}
```

`mountRoot` and `statePath` resolve relative to the manifest file. `workerMountRoot`
is the repo-relative read-only mount workers receive. With the recommended
`mountRoot` and `statePath` resolve relative to the manifest file. `statePath`
is a local receipt cache, not the dispatch authority: before creating an issue
or spawning an agent, Factory creates one immutable, digest-bound claim in the
active Agent Relay workspace. Workspace-global claim-channel uniqueness stops
two machines with independent caches from dispatching the same source key. A
failed or ambiguous claim write blocks dispatch. All non-dry-run Notion intake
therefore requires a resolvable active Agent Relay workspace key.

`workerMountRoot` is the repo-relative read-only mount workers receive. With the recommended
`relay-channel` transport, Factory base64-chunks the digest-bound mounted bytes
into a workspace-private Agent Relay channel. A worker on any fleet machine can
reconstruct the exact file at `workerMountRoot`, set it to mode `0444`, and
apply the source SHA-256 gate without exposing the page in a public issue.
The field defaults to `{ "kind": "local" }` whenever it is omitted, including
in new manifests. Portable delivery must be selected explicitly and requires a
resolvable active Agent Relay workspace key; otherwise dispatch fails closed.
`workerMountTransport` defaults to `{ "kind": "local" }` whenever it is
omitted, including in new manifests. Portable delivery must be selected
explicitly; otherwise the worker uses the local mount path. Missing portable
delivery or claim capability fails closed.

If dispatch stops after creating the durable claim but before recording its
receipt, re-running stays blocked rather than guessing whether the downstream
side effect happened. The blocked result includes the source key. A workspace
administrator can derive the claim channel as
`factory-notion-claim-<sha256(sourceKey)>`. After verifying that no matching
lifecycle issue or workspace agent exists, the administrator may delete that
channel in Agent Relay and re-run intake. Never clear a claim merely because
its local receipt is missing: the shared claim, issue marker, and running agent
must be reconciled first. A blocked portable-mount migration uses the same
channel formula with `<sourceKey>:portable-mount` as the hashed value; verify
the worker was not redispatched before clearing it.
`page` accepts a Notion URL or a bare page ID.

Plan without writes, then dispatch:
Expand All @@ -227,9 +256,9 @@ factory intake notion ./ops/notion-intake.json --backend relay
Repository targets require the `factory-ready` and matching
`agent:<recipe>` labels to already exist. Factory automatically prefixes their
issue titles with `[factory]` so the hosted brain's independent safety gate can
accept them. Re-running is idempotent: GitHub work
is claimed by a hidden source marker, while exact-path dispatches use a local
digest-bound receipt. A changed mounted spec blocks instead of silently mutating
accept them. Re-running reconciles the workspace-global claim with the GitHub
source marker or the running exact-path agent; the local digest-bound receipt is
only a cache. A changed mounted spec blocks instead of silently mutating
already-dispatched work.

### Feature-map validation
Expand Down Expand Up @@ -538,7 +567,13 @@ Tokens involved — set only the first one on the orchestrator host:

## Configuration

Pass a JSON file via `--config`. Beyond the two required fields above, useful
Factory resolves exactly one contract: the path passed via `--config`, or
`./factory.config.json` in the command's current working directory when the
flag is omitted. It does not search target repositories, walk to a clone root,
or merge multiple configs; a config in another repository is inert unless it
is selected explicitly.

Beyond the two required fields above, useful
knobs include issue **routing** (`repos.byLabel` / `byProject` / `keywordRules` /
`default`), the **safety gate** (`safety.requireTitlePrefix`, `safety.requireTeamKey`),
`mergePolicy` (defaults to `never`), per-role **model** overrides, and an optional
Expand Down
96 changes: 96 additions & 0 deletions docs/notion-ticket-feeder-assessment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Notion as a first-class Factory ticket feeder

Date: 2026-08-06

## Decision summary

Notion should remain a separate, explicit intake command that normalizes
repository tasks into GitHub lifecycle issues or dispatches exact-path fleet
work. It should not become a third `issueSource` until Factory has both native
Notion discovery semantics and a guarded lifecycle-writeback adapter. The
immediate correctness gap is cross-dispatcher claim durability.

## 1. Claim durability

**Assessment:** defect; fix now.

The prior `.factory/notion-intake-state.json`-style receipt was protected only
by a same-filesystem lock. Two machines using different state paths could both
observe no receipt and dispatch the same stable `notion:<page-id>:<target>` work
unit. The stable source identity and digest were already correct; the authority
was on the wrong surface.

**Recommendation and implementation:** use one immutable Agent Relay claim
channel per hashed source key. Channel-name uniqueness in the shared workspace
is the atomic cross-dispatcher gate. The winner must receive an acknowledged,
digest-bound claim record before GitHub issue creation, lifecycle publication,
agent spawn, or redispatch. A second dispatcher observes the record and cannot
spawn. A failed or ambiguous record write leaves an incomplete claim channel
and blocks subsequent dispatch, favoring an operator-visible stranded claim
over duplicate real work.

The manifest `statePath` remains a local receipt cache for reconciliation and
migration. It is no longer the authority. Agent Relay message idempotency was
not used as the claim primitive because it is sender-scoped and time-bounded;
workspace-global channel uniqueness supplies the required durable exclusion.

## 2. Lifecycle reconciliation to Notion

**Assessment:** deliberate boundary worth keeping now, not a defect to paper
over in Factory.

Relayfile currently presents the Notion page as a read-only execution contract.
Factory has guarded writeback adapters and lifecycle mappings for Linear,
Slack, and GitHub, but no Notion property/comment writeback contract. A native
Notion lifecycle would also need an operator-selected mapping for accepted,
dispatched, PR opened, blocked, and completed states. Inventing that mapping or
assuming a writable provider behind a read-only mount would weaken the existing
fail-closed model.

**Recommendation:** keep the page immutable and reconcile repository work on
the generated GitHub issue. Treat native Notion lifecycle as a separate product
decision requiring a real provider capability, guarded acknowledgement/readback
semantics, and a configured database/property mapping.

## 3. `issueSource` and routing-contract visibility

**Assessment:** terminology/documentation ambiguity; do not add `notion` to the
enum yet.

`issueSource` selects the orchestrator's discovery and lifecycle-writeback
adapter. Notion intake is intentionally an upstream normalization step, not an
adapter implementing that contract. Accepting `issueSource: "notion"` today
would promise discovery, triage, state transitions, comments, and terminal
reconciliation that do not exist.

**Recommendation:** retain `linear | github`, explicitly define the field as a
lifecycle issue source, and document that `factory intake notion` normalizes
into GitHub lifecycle or exact-path fleet work. A future native Notion adapter
can extend the enum only when it satisfies the whole lifecycle port.

## Contract resolution

Factory resolves exactly one config: the path supplied with `--config`, or
`./factory.config.json` in the command's current working directory. It does not
search the target repository, walk to a clone root, or merge configs. Notion
intake does not create an implicit fallback layer.

## Proof and current operational limitation

Automated coverage proves both repository publication and exact-path spawn are
preceded by the shared claim, claim-write failure prevents the external action,
and two manifests with independent local state paths produce only one issue or
one spawn.

Chief also has prior live evidence: the mounted Notion benchmark page produced
agent `notion-9a84f582-8fc3bc47` on `kjg-laptop`, with a durable fleet invocation
record and a portable Relay delivery receipt. Repository pages produced labeled
GitHub lifecycle issues (for example Cloud #2935 and Relay #1433).

A fresh production dispatch was intentionally not created during this change:
every page in Chief's active manifest already has a receipt, so another task
would be duplicate real work. `relayfile status` reports the Notion provider as
lagging with no sync cursor/watermark and the Chief snapshots are dated August
5. The remote page is currently readable and its digest matches the local
snapshot checked, but that does not establish fresh provider ingress. A new-page
live proof remains blocked until a fresh Notion sync watermark is observable.
3 changes: 3 additions & 0 deletions src/__tests__/dist-entrypoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ describe('published dist entrypoints', () => {
const featureGuardian = await import('../../dist/feature-guardian/index.js')
const main = await import('../../dist/index.js')
const hosted = await import('../../dist/hosted/index.js')
const intake = await import('../../dist/intake/index.js')
const environments = await import('../../dist/environments/index.js')
const testing = await import('../../dist/testing/index.js')
const writeback = await import('../../dist/writeback/index.js')
Expand All @@ -29,6 +30,8 @@ describe('published dist entrypoints', () => {
expect(main.KubernetesEnvironmentProvider).toBeTypeOf('function')
expect(hosted.createHostedFactory).toBeTypeOf('function')
expect(hosted.DurableObjectHostedFactoryStateStore).toBeTypeOf('function')
expect(intake.RelayChannelNotionClaimStore).toBeTypeOf('function')
expect(intake.runNotionIntake).toBeTypeOf('function')
expect(environments.KubernetesEnvironmentProvider).toBeTypeOf('function')
expect(environments.KubectlEnvironmentProvider).toBeTypeOf('function')
expect(environments.VerificationPipeline).toBeTypeOf('function')
Expand Down
46 changes: 45 additions & 1 deletion src/cli/fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,9 +591,21 @@ describe('fleet CLI runtime', () => {
await writeFile(manifestPath, JSON.stringify(manifest))
const output = buffer()
const fleet = new FakeFleetClient()
const durableClaims = new Map<string, { sourceKey: string; digest: string; claimedAt: string }>()
const notionClaims = {
get: vi.fn(async (sourceKey: string) => durableClaims.get(sourceKey)),
claim: vi.fn(async (claim: { sourceKey: string; digest: string; claimedAt: string }) => {
const existing = durableClaims.get(claim.sourceKey)
if (existing) return { status: 'existing' as const, claim: existing }
Comment thread
barryollama marked this conversation as resolved.
durableClaims.set(claim.sourceKey, claim)
return { status: 'claimed' as const, claim }
}),
dispose: vi.fn(async () => undefined),
}

const code = await runFleetCli(['intake', 'notion', manifestPath], {
fleet,
notionClaims,
stdout: output,
stderr: buffer(),
})
Expand Down Expand Up @@ -622,6 +634,7 @@ describe('fleet CLI runtime', () => {
const migratedErrors = buffer()
const migratedCode = await runFleetCli(['intake', 'notion', manifestPath], {
fleet,
notionClaims,
notionContracts: contracts,
env: {},
stdout: migratedOutput,
Expand All @@ -636,11 +649,42 @@ describe('fleet CLI runtime', () => {
mode: 'steer',
})])
expect(contracts.dispose).toHaveBeenCalledOnce()
expect(notionClaims.dispose).toHaveBeenCalledTimes(2)
expect(migratedErrors.text()).toContain('Notion contract publisher failed during shutdown')
expect(JSON.parse(migratedOutput.text())).toMatchObject({
ok: true,
results: [{ status: 'already-dispatched', target: { projectPath } }],
})

await writeFile(join(mountedPage, 'content.md'), [
'# Chief Spec',
'Status: ready',
'Title: Changed exact-path dispatch',
'Summary: This digest must not reuse the durable claim.',
'Recipe: single',
`Project-Paths: ${projectPath}`,
].join('\n'))
await writeFile(manifestPath, JSON.stringify({
...manifest,
statePath: './changed-state.json',
}))
const changedOutput = buffer()
const changedCode = await runFleetCli(['intake', 'notion', manifestPath], {
fleet,
notionClaims,
stdout: changedOutput,
stderr: buffer(),
})

expect(changedCode).toBe(1)
expect(fleet.spawns).toHaveLength(1)
expect(JSON.parse(changedOutput.text())).toMatchObject({
ok: false,
results: [{
status: 'blocked',
reason: 'durable Notion claim digest does not match the mounted spec',
}],
})
} finally {
await rm(root, { recursive: true, force: true })
}
Expand Down Expand Up @@ -676,7 +720,7 @@ describe('fleet CLI runtime', () => {
})

expect(code).toBe(1)
expect(errors.text()).toContain('requires an active Agent Relay workspace')
expect(errors.text()).toContain('requires an active Agent Relay workspace for its durable shared claim')
} finally {
await rm(root, { recursive: true, force: true })
}
Expand Down
Loading