Skip to content

octosense-app-contract 1.0.0: the app contract (OctoSense ADR 0005) - #46

Merged
ymote merged 7 commits into
mainfrom
feat/app-contract
Oct 1, 2026
Merged

ymote merged 7 commits into
mainfrom
feat/app-contract

Conversation

@ymote

@ymote ymote commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

octosense-app-contract 1.0.0 (OctoSense ADR 0005, step 2)

Creates crates/app-contract, the crate apps and hosts will depend on by version (octosense-app-contract = "1"). Its code is moved out of octosense-app-policy with the behaviour unchanged, and app-policy re-exports it. This PR does not publish anything. Publishing is the manual publish-app-contract workflow, run after review.

The contract surface (ADR §1)

  • Manifest: AppManifest and its parts (Integrity, Signature, Network, Storage, Compute, AgentSpec, ModelSpec, TaskModel, Triggers, ProfileMode, ModelNeed, ModelTier, AgentWorkspace, ResearchScope), SCHEMA, SCHEMA_MINOR, KNOWN_FEATURES, KNOWN_CAPABILITIES, MANIFEST_FILE, parse
  • Policy: policy::resolve, HostLimits (with with_* builders), AppPolicy. AppPolicy holds the capabilities, hosts, the instruction and memory budgets, may_prompt, the research scope, and the whole storage block as StorageGrant (max_bytes clamped, accounts, agent_workspace, cache_max_bytes). external isn't in a script app's manifest, so it isn't in StorageGrant either.
  • Integrity: digest_dir, bundle_digest, admit, admit_digest, SignatureVerifier, RefuseAllSignatures
  • Running a package: SCRIPT_ENTRY, script_source, ASSETS_PLACEHOLDER, AssetServer, StaticAssets, rewrite_assets

The contract depends on three crates.io crates and nothing else: serde (with derive), serde_json and blake3.

These stay in app-policy: IsolateSettings and SessionProfile, splash_adapter, agents (AgentPolicy, AgentBundle, KERNEL_TOOLS), listings, the research words and the service registry.

The AppPolicy split

  • The contract's AppPolicy covers what the app may do. It is #[non_exhaustive] and serialises.
  • app-policy's AppPolicy is now { app: contract::AppPolicy, agent: Option<AgentPolicy> } and derefs to the contract's type. Field reads and allows/allows_host work as before.
  • app-policy's policy::resolve runs the contract's rules first, then resolves the agent. That is the same order and the same error messages as before.
  • One source-level difference: code that moves a field out of the policy, such as policy.research.unwrap(), now has to write policy.app.research. Two of App Hub's own tests needed this change. OctoSense and Rinx v1.0.2 compile unchanged.

Growth rules (ADR §2)

Additive growth without 2.0. Every public struct and enum is #[non_exhaustive].

  • The one exception is the unit marker RefuseAllSignatures, because &RefuseAllSignatures must stay constructible.
  • There are no error enums: every error is a String.
  • Consumers build values with:
    • HostLimits::default() or HostLimits::system(), plus with_* builders
    • Signature::new
    • parse for manifests, policy::resolve for policies
  • On a #[non_exhaustive] type, adding a public field or an enum variant is a minor change for cargo semver-checks. Adding the attribute later would be a major change, which is why it goes in now.

Struct literals that must change:

  • App Hub: hub.rs, gate.rs, card-host/src/host.rs, signing.rs (now Signature::new) and three test files. All are updated in this PR.
  • Rinx (steps 2–3), &HostLimits { require_signature: false, ..Default::default() } becomes &HostLimits::default().with_require_signature(false) at:
    • tools/miniapp-package/src/main.rs:19
    • crates/system-apps/src/lib.rs:170
    • src/miniapps/package.rs:103
  • OctoSense: none.

Unknown fields are classified, not ignored. parse applies these rules in order:

  1. schema must be 1.
  2. Every requires entry must be in KNOWN_FEATURES, at every schema_minor. Otherwise the manifest is refused with app <id> needs a newer host: <feature>.
  3. If schema_minor ≤ SCHEMA_MINOR, the manifest is parsed strictly, exactly as before.
  4. If schema_minor > SCHEMA_MINOR, unknown fields at any level are ignored:
    • They are dropped by a deserializer over serde_json::Value, using the field names each struct passes to serde.
    • AppManifest::ignored_fields() lists them, sorted.
    • Known fields are still checked as always, and the old research shape is still refused.
    • The ignored fields are restored into the signing bytes, so a newer signed manifest still verifies.

In 1.0.0, KNOWN_FEATURES is empty and SCHEMA_MINOR is 0. Both new manifest fields are left out of the signing bytes when they are empty.

The rules are documented in the rustdoc "Stability" section and in crates/app-contract/README.md.

Fixtures

tests/fixtures/ holds 27 fixtures:

  • six OctoSense system apps
  • App Hub's app template and its news-agent test bundle
  • two Rinx mini apps (one keeps its shipped digest)
  • five cases from the admission tests
  • ten refusals
  • two manifests written for a newer 1.x: one with optional unknown fields (admitted, fields reported), one with an unknown required feature (refused)

Each fixture pins:

  • the package digest
  • the digest of the manifest's signing bytes
  • the ignored fields
  • the entry kind
  • the resolved AppPolicy, or the refusal message

The corpus is append-only within 1.x. APP_CONTRACT_BLESS=1 only fills in keys that are missing.

CI

There were no workflows on main, so both workflows are new. Both copy the crate out of the workspace, because the workspace's [patch] tables need the sibling checkouts.

  • app-contract.yml has two jobs:
    • tests, clippy -D warnings, cargo package --list and cargo publish --dry-run
    • cargo semver-checks against the latest version on crates.io. It skips with a notice while the sparse index returns 404.
  • publish-app-contract.yml runs from workflow_dispatch or an app-contract-v* tag:
    • it refuses a version that is already published
    • it runs the tests
    • it publishes with secrets.CARGO_REGISTRY_TOKEN in the crates-io environment

Checks

Check Result
cargo test --workspace 207 passed
clippy -D warnings on the contract and app-policy clean
octosense-app-policy --features splash builds
Standalone copy of the contract tests and clippy pass
cargo package --list 107 files, no stray files
cargo publish --dry-run passes
OctoSense main, with a local [patch] pointing at this branch: cargo check -p octosense passes (needs local edits, see below)
Same setup: phone with cargo check --features mobile-apps passes (same local edits)

The local edits, none of them committed:

  • Rinx v1.0.2 with the three builder edits above, also [patch]ed locally. Unmodified, Rinx v1.0.2 fails at exactly those HostLimits literals (E0639).
  • A one-line shim in OctoSense for ServiceCall.may_prompt, which App Hub Host requests always settle, and the surface decides about sheets #38 added and OctoSense hasn't adopted yet. This PR doesn't cause it.

Before publishing

  • Add the CARGO_REGISTRY_TOKEN secret (publish-new scope for the first release).
  • Optionally, add required reviewers to the crates-io environment.

🤖 Generated with Claude Code

ymote and others added 7 commits September 30, 2026 20:45
…licy

OctoSense ADR 0005 step 2. The new crate crates/app-contract holds exactly
the contract surface of ADR section 1, moved out of octosense-app-policy
with its behaviour unchanged:

- manifest: AppManifest and its parts, SCHEMA, MANIFEST_FILE, parse
- policy: policy::resolve, HostLimits, AppPolicy (capabilities, hosts,
  storage, budgets, research scope)
- integrity: digest_dir, bundle_digest, admit, admit_digest,
  SignatureVerifier, RefuseAllSignatures
- running a package: SCRIPT_ENTRY, script_source, ASSETS_PLACEHOLDER,
  AssetServer, StaticAssets, rewrite_assets

It depends on serde, serde_json and blake3 from crates.io only.

AppPolicy is split: the contract's AppPolicy is what the app may do;
app-policy's AppPolicy is the host type { app, agent } and derefs to the
contract's, so field reads and allows()/allows_host() read as before. The
agent is resolved after the contract's rules, in the same order as before.
IsolateSettings, the Splash adapter, agents, listings, research words and
the service registry stay in app-policy, which depends on the contract by
path and version and re-exports it (as `contract`, and item by item).

Growth rules (ADR section 2): the parser stays strict; the manifest gains
`requires` and `schema_minor` (default 0), both skipped from the signing
bytes when empty; a manifest requiring a feature outside KNOWN_FEATURES
(empty in 1.0.0) is refused with "needs a newer host: <feature>". The
manifest's structs and enums, ResearchScope and AppPolicy are
non_exhaustive so they can grow within 1.x. Documented in the crate docs'
Stability section and the crate README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
25 fixtures under crates/app-contract/tests/fixtures: the shell's system
apps (OctoSense apps/*: ai-providers, camera, mail, maps, news, youtube),
App Hub's app template and news-agent test bundle, two Rinx mini apps
(one with its shipped digest), the admission tests' cases (net and hosts,
research and crawl scopes, the storage block, host services, schema_minor)
and ten refusals (an unknown required feature, an unknown field, a foreign
schema, a digest mismatch, unsigned, a reserved id...).

Each pins the package digest, the manifest's signing-bytes digest, the
entry kind and the resolved AppPolicy (or the refusal). tests/fixtures.rs
loads every one and compares; a later 1.x may add policy fields or say
more in a refusal, never something else. APP_CONTRACT_BLESS=1 fills in a
new fixture's missing keys and never changes existing ones: the corpus is
append-only within 1.x (fixtures/README.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.github/workflows/app-contract.yml, on changes to crates/app-contract:
the crate is copied out of the workspace (whose [patch] tables name
sibling checkouts it does not use) and built as crates.io will build it:
tests with the fixture corpus, doc tests, clippy -D warnings, cargo
package --list and cargo publish --dry-run.

The semver job runs cargo semver-checks against the latest published
version from crates.io's sparse index, so a non-additive change fails the
pull request (ADR 0005 sections 2 and 5). Until a first version is
published the index answers 404 and the job skips with a notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.github/workflows/publish-app-contract.yml runs on workflow_dispatch or an
app-contract-v<version> tag (which must name the crate's version). It
refuses a version crates.io already has, runs the tests and the fixture
corpus, and publishes octosense-app-contract with the
CARGO_REGISTRY_TOKEN secret, in the crates-io environment so reviewers can
be required before the token is used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README tables list crates/app-contract, and PUBLISHING.md points at
the contract's entry, manifest and research files, which moved there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 1.x

Two things 1.0.0 must get right, since a published version is permanent.

Every public struct and enum is #[non_exhaustive] (the unit marker
RefuseAllSignatures excepted, so `&RefuseAllSignatures` still builds), so
a field or variant added in 1.x is a minor change. HostLimits gains
with_* builders and Signature gains new(); App Hub's call sites use them
(`HostLimits { require_signature: x, ..HostLimits::default() }` becomes
`HostLimits::default().with_require_signature(x)`).

ADR 0005 section 2's optional/required rule, in full:
- schema must be 1, and every `requires` entry must be in KNOWN_FEATURES,
  at every schema_minor ("needs a newer host: <feature>");
- schema_minor <= SCHEMA_MINOR: read strictly, as before;
- schema_minor > SCHEMA_MINOR: unknown fields at any level are optional by
  rule, so they are ignored (a deserializer over serde_json::Value drops
  the keys each struct does not name) and listed, sorted, by
  AppManifest::ignored_fields(). Known fields are checked as always, and
  the old research shape is still refused. The ignored fields are put back
  into the signing bytes, so a newer signed manifest still verifies.

AppPolicy also carries the manifest's whole storage block as StorageGrant
(max_bytes clamped, accounts, agent_workspace, cache_max_bytes); the
fixtures' pinned policies gain it and the ignored fields, nothing else.
`external` is not part of a script app's manifest, so it is not there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test-newer-minor-optional (schema_minor 2, unknown optional fields at the
top level, in network and in storage: admitted, fields reported) and
refuse-newer-minor-requires (an unknown required feature: refused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ymote
ymote merged commit 0326a5c into main Oct 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant