Skip to content
Closed
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
31 changes: 16 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ is how to work *in* the repo.

- **Owns:** the generated message bindings (`saas_sdk._gen`); the gateway-bound
`datasource` facade; the consumer-side halves of Work Context — the mint
client and the verifier (`saas_sdk.work_context`); the current-revision client
(`saas_sdk.authorization_revision`).
client (`saas_sdk.work_context`); the host verification transport
(`saas_sdk.verification`); the current-revision client
(`saas_sdk.authorization_revision`). The existing local verifier parses Core
protobuf and checks signatures; it does not resolve live sealed authority.
- **Does not own:** the protos, which live in `module-saas-starter`
(`module/services/accounts/proto`); the Connect transport, which the solution
runtime owns behind `Gateway.unary`; the Work Context *signer*, which stays
authority-side in `sdk-go`; the wire format, which is pinned to `sdk-go`.
runtime owns behind `Gateway.unary`; canonical Work Context minting, wire
format and verification, which belong to `core/workcontext`. Current composed
consumers use the host port; Python never reimplements its sealed verification.
- **Never hand-edit `src/saas_sdk/_gen/`.** It is regenerated from a proto ref
recorded in `SOURCE.txt` — see the `regenerate-bindings` skill.
- Everything ships under the single top-level package `saas_sdk`, so installing
Expand Down Expand Up @@ -49,20 +52,18 @@ uninstallable for consumers still on protobuf 5.x.

## Parity with saas-sdk-go is a cross-repo invariant

Two fixtures are byte-identical to their Go counterparts, and CI in both repos
verifies the same bytes:
The Work Context fixture tests the existing Core-wire parser; the authorization
revision fixture remains shared with the Go SDK. Do not change either merely to
make a test pass. Core owns the Work Context encoding, and the host owns live
sealed verification. Keep the existing parser available to its consumers without
reimplementing the host's live resolver in Python.

- `tests/fixtures/work_context_wire_golden.json` ↔ sdk-go's
`TestWorkContextWireGolden`. The wire format is **not** a JWT; field order,
`uint64`-as-decimal-string encoding, scope canonicalization, base64url and
Ed25519 signing are pinned byte-for-byte.
- `tests/fixtures/authorization_revision_projection.json` ↔ the Go SDK's
- `tests/fixtures/work_context_wire_golden.json` pins an authentic Core token.
- `tests/fixtures/authorization_revision_projection.json` matches the Go SDK's
`authorizationrevision/testdata/projection.json`.

Editing a fixture to make a test here pass is not a fix — it is a silent break
of the other implementation, which will keep minting or verifying the old bytes.
A wire change is one coordinated pair of pull requests. See the
`wire-format-parity` skill.
Current-host live verification is tested through the generated host RPC and the
opaque-token transport in `verification`, with Core conformance at its owner.

## When something does not work

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

- Add `verification.Client` for the host's idempotent-only Core verification
operation on the resolved `accounts/authority` endpoint. Credentials remain
opaque; request-local results are rechecked remotely without replay writes.
- Breaking: `work_context.Client.start_task` no longer accepts `session_id`.
The current host assigns session identity and removed that request field.
Retain the existing Core-wire parser and signature-verification helpers;
composed consumers needing live authority use the host verification client.

- Add `datasource.invoke`, `lookup`, `declare_operations` and
`list_operations`, also available on the existing gateway-bound client.
- Add operation declarations, effects, results, receipts, UUIDv7 effect IDs,
Expand Down
31 changes: 23 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ Everything ships under a single top-level package, `saas_sdk`, so installing it
never claims a generic name (`saas`, `buf`, `datasource`) in the consumer's
import namespace.

Current Core Work Contexts are verified through `saas_sdk.verification.Client`
on Codefly's resolved `accounts/authority` endpoint. Construct it with the
callee's trusted issuer and audience and an async internal-credential callback;
`await client.verify(token)` returns request-local identity and effective scopes.
Apply resource policy locally and call `await client.recheck(result)` at each
live-authority checkpoint. Close the client with `await client.aclose()`.
The host checks the opaque credential through Core, including live installation,
epoch, execution and binding state. Only idempotent credentials are accepted.
The three-second budget includes credential refresh and the RPC. Authority
outages return `AuthorityUnavailable`; invalid credentials and denied authority
are distinct. No failure yields an identity. Tokens and results must not be logged
or persisted. `work_context.Verifier` and the JWKS helpers also parse Core
protobuf capabilities and verify their signatures, but do not establish the live
installation and binding state checked by the host port. Existing users of
those helpers retain their interface; composed callees requiring live authority
use the host verification client.

Packages:

- **`saas_sdk._gen`** — private; consumers reach these types through the facades
Expand Down Expand Up @@ -253,13 +270,11 @@ solution needs: the **mint-side** client to the accounts authority, and the
**callee-side** verifier. Only the *signer* itself stays authority-side in
[`sdk-go`](https://github.com/codefly-dev/sdk-go).

The wire format is **not a JWT**. A token is
`base64url(JSON payload).base64url(Ed25519 sig)`, the payload is a fixed
snake_case JSON object, and `key_id` lives *inside* the payload. Field order,
`uint64`-as-decimal-string encoding, scope canonicalization, base64url, and
Ed25519 signing are pinned byte-for-byte to `sdk-go`: the wire golden in
`tests/fixtures/work_context_wire_golden.json` is byte-identical to sdk-go's
`TestWorkContextWireGolden`, and CI in both repos verifies the same token.
The wire format is **not a JWT**. A token carries a Core `WorkContextV1`
protobuf payload and an Ed25519 signature. Core owns its canonical encoding;
the SDK's wire fixture tests the parser against an authentic Core capability.
Signature verification alone does not establish current authority: the host
verification client above checks live state through the owning authority.

### Minting (delegated caller)

Expand All @@ -272,7 +287,7 @@ from saas_sdk import work_context

wc = work_context.new(gateway)
parent = wc.start_task(
bearer=user_bearer, org_id=org, task_id=task, session_id=session,
bearer=user_bearer, org_id=org, task_id=task,
audience="accounts",
scopes=[work_context.WorkContextScope(resource_kind="evidence", actions=["read"])],
)
Expand Down
28 changes: 16 additions & 12 deletions SOURCE.txt
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
src/saas_sdk/_gen generated from codefly-dev/module-saas-starter@v0.0.94
DEVELOPMENT CANDIDATE — not a published host release.
src/saas_sdk/_gen regenerated from codefly-dev/module-saas-starter@2e1fdb5c1b6bf7594db75d76cbe6a1518ca7cde0
(accounts public proto: module/services/accounts/proto)
Release tag: https://github.com/codefly-dev/module-saas-starter/releases/tag/v0.0.94
Annotated tag object: 68f3fd38c721724166ee37e1f5ca75441fd76661
Resolved commit: 9f6a4aa26edcbcb4d76cf4ffb47f71010e94f4eb
services:
saas.accounts.v1.DatasourceService (datasource.proto)
saas.accounts.v1.WorkContextService (work_contexts.proto)
messages:
saas.jobs.v1 (jobs.proto, as saas_sdk/_gen/jobs.proto: datasource.proto's sync state)
custom options + shared descriptors stripped — see scripts/generate.sh
Resolved commit: 2e1fdb5c1b6bf7594db75d76cbe6a1518ca7cde0

module version: 0.1.0 (module/module.package.codefly.yaml)
This SDK's release is matched to the saas-starter module version above.
Inputs (SHA-256):
43d579e29dbfe39a2d80cb7efe7e360cbcbf951ee54772f7e28bfca10a0dfaab saas/jobs/v1/jobs.proto
8c7431b2e934fe86601bb0e13db039618565a0dc3a6ce6ddcb9468b908bbb319 saas/accounts/v1/datasource.proto
a9b70803c33add5222fbe5b18bcd1696205a107d8016e5992d0b2d47aefd7250 saas/accounts/v1/work_contexts.proto

Generation: scripts/generate.sh <host>/module/services/accounts/proto
Custom options/shared descriptors stripped by scripts/strip_options.py.
Core WorkContext bindings and parser retained from SDK main a954baf641068568c106dbb96e19a408cfdf4c62.

Module version baseline: 0.1.0. No release is asserted.
RELEASE GATE: the maintained gen-drift workflow requires a released host tag.
After the host release, regenerate from that tag and record its tag object and
resolved commit here. This candidate must not be released before that check passes.
356 changes: 193 additions & 163 deletions src/saas_sdk/_gen/datasource_pb2.py

Large diffs are not rendered by default.

186 changes: 186 additions & 0 deletions src/saas_sdk/_gen/datasource_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class SourceSyncTrigger(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
SOURCE_SYNC_TRIGGER_MANUAL: _ClassVar[SourceSyncTrigger]
SOURCE_SYNC_TRIGGER_SCHEDULED: _ClassVar[SourceSyncTrigger]
SOURCE_SYNC_TRIGGER_WEBHOOK: _ClassVar[SourceSyncTrigger]
SOURCE_SYNC_TRIGGER_RECONFIGURED: _ClassVar[SourceSyncTrigger]

class DatasourceDomainStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
Expand Down Expand Up @@ -162,6 +163,7 @@ SOURCE_SYNC_TRIGGER_UNSPECIFIED: SourceSyncTrigger
SOURCE_SYNC_TRIGGER_MANUAL: SourceSyncTrigger
SOURCE_SYNC_TRIGGER_SCHEDULED: SourceSyncTrigger
SOURCE_SYNC_TRIGGER_WEBHOOK: SourceSyncTrigger
SOURCE_SYNC_TRIGGER_RECONFIGURED: SourceSyncTrigger
DATASOURCE_DOMAIN_STATUS_UNSPECIFIED: DatasourceDomainStatus
DATASOURCE_DOMAIN_STATUS_PENDING: DatasourceDomainStatus
DATASOURCE_DOMAIN_STATUS_VERIFIED: DatasourceDomainStatus
Expand Down Expand Up @@ -354,6 +356,40 @@ class AddSourceResponse(_message.Message):
datasource: Datasource
def __init__(self, datasource: _Optional[_Union[Datasource, _Mapping]] = ...) -> None: ...

class UpdateSourceRequest(_message.Message):
__slots__ = ("org_id", "id", "branch", "paths", "file_extensions", "expected")
ORG_ID_FIELD_NUMBER: _ClassVar[int]
ID_FIELD_NUMBER: _ClassVar[int]
BRANCH_FIELD_NUMBER: _ClassVar[int]
PATHS_FIELD_NUMBER: _ClassVar[int]
FILE_EXTENSIONS_FIELD_NUMBER: _ClassVar[int]
EXPECTED_FIELD_NUMBER: _ClassVar[int]
org_id: str
id: str
branch: str
paths: _containers.RepeatedScalarFieldContainer[str]
file_extensions: _containers.RepeatedScalarFieldContainer[str]
expected: SourceFetchScope
def __init__(self, org_id: _Optional[str] = ..., id: _Optional[str] = ..., branch: _Optional[str] = ..., paths: _Optional[_Iterable[str]] = ..., file_extensions: _Optional[_Iterable[str]] = ..., expected: _Optional[_Union[SourceFetchScope, _Mapping]] = ...) -> None: ...

class SourceFetchScope(_message.Message):
__slots__ = ("branch", "paths", "file_extensions")
BRANCH_FIELD_NUMBER: _ClassVar[int]
PATHS_FIELD_NUMBER: _ClassVar[int]
FILE_EXTENSIONS_FIELD_NUMBER: _ClassVar[int]
branch: str
paths: _containers.RepeatedScalarFieldContainer[str]
file_extensions: _containers.RepeatedScalarFieldContainer[str]
def __init__(self, branch: _Optional[str] = ..., paths: _Optional[_Iterable[str]] = ..., file_extensions: _Optional[_Iterable[str]] = ...) -> None: ...

class UpdateSourceResponse(_message.Message):
__slots__ = ("datasource", "job_id")
DATASOURCE_FIELD_NUMBER: _ClassVar[int]
JOB_ID_FIELD_NUMBER: _ClassVar[int]
datasource: Datasource
job_id: str
def __init__(self, datasource: _Optional[_Union[Datasource, _Mapping]] = ..., job_id: _Optional[str] = ...) -> None: ...

class DatasourceConfigField(_message.Message):
__slots__ = ("key", "display_name", "help", "required")
KEY_FIELD_NUMBER: _ClassVar[int]
Expand Down Expand Up @@ -908,6 +944,156 @@ class RevokeSourceDelegationResponse(_message.Message):
delegation: SourceDelegation
def __init__(self, delegation: _Optional[_Union[SourceDelegation, _Mapping]] = ...) -> None: ...

class TriggerIdentity(_message.Message):
__slots__ = ("source", "binding")
SOURCE_FIELD_NUMBER: _ClassVar[int]
BINDING_FIELD_NUMBER: _ClassVar[int]
source: str
binding: str
def __init__(self, source: _Optional[str] = ..., binding: _Optional[str] = ...) -> None: ...

class DeclaredTriggerSchedule(_message.Message):
__slots__ = ("cron", "timezone", "missed_run", "overlap", "deadline", "input_json")
CRON_FIELD_NUMBER: _ClassVar[int]
TIMEZONE_FIELD_NUMBER: _ClassVar[int]
MISSED_RUN_FIELD_NUMBER: _ClassVar[int]
OVERLAP_FIELD_NUMBER: _ClassVar[int]
DEADLINE_FIELD_NUMBER: _ClassVar[int]
INPUT_JSON_FIELD_NUMBER: _ClassVar[int]
cron: str
timezone: str
missed_run: str
overlap: str
deadline: int
input_json: str
def __init__(self, cron: _Optional[str] = ..., timezone: _Optional[str] = ..., missed_run: _Optional[str] = ..., overlap: _Optional[str] = ..., deadline: _Optional[int] = ..., input_json: _Optional[str] = ...) -> None: ...

class TriggerDelegationReference(_message.Message):
__slots__ = ("reference", "delegation_id", "module", "binding", "audience", "tenant", "owner_principal_id", "scopes", "active", "created_at", "revoked_at", "revocation")
REFERENCE_FIELD_NUMBER: _ClassVar[int]
DELEGATION_ID_FIELD_NUMBER: _ClassVar[int]
MODULE_FIELD_NUMBER: _ClassVar[int]
BINDING_FIELD_NUMBER: _ClassVar[int]
AUDIENCE_FIELD_NUMBER: _ClassVar[int]
TENANT_FIELD_NUMBER: _ClassVar[int]
OWNER_PRINCIPAL_ID_FIELD_NUMBER: _ClassVar[int]
SCOPES_FIELD_NUMBER: _ClassVar[int]
ACTIVE_FIELD_NUMBER: _ClassVar[int]
CREATED_AT_FIELD_NUMBER: _ClassVar[int]
REVOKED_AT_FIELD_NUMBER: _ClassVar[int]
REVOCATION_FIELD_NUMBER: _ClassVar[int]
reference: str
delegation_id: str
module: str
binding: str
audience: str
tenant: str
owner_principal_id: str
scopes: _containers.RepeatedScalarFieldContainer[str]
active: bool
created_at: _timestamp_pb2.Timestamp
revoked_at: _timestamp_pb2.Timestamp
revocation: SourceDelegationRevocation
def __init__(self, reference: _Optional[str] = ..., delegation_id: _Optional[str] = ..., module: _Optional[str] = ..., binding: _Optional[str] = ..., audience: _Optional[str] = ..., tenant: _Optional[str] = ..., owner_principal_id: _Optional[str] = ..., scopes: _Optional[_Iterable[str]] = ..., active: bool = ..., created_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., revoked_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., revocation: _Optional[_Union[SourceDelegationRevocation, str]] = ...) -> None: ...

class TriggerChildContext(_message.Message):
__slots__ = ("delegation_id", "module", "binding", "audience", "scopes", "lookup", "minted_at", "record")
DELEGATION_ID_FIELD_NUMBER: _ClassVar[int]
MODULE_FIELD_NUMBER: _ClassVar[int]
BINDING_FIELD_NUMBER: _ClassVar[int]
AUDIENCE_FIELD_NUMBER: _ClassVar[int]
SCOPES_FIELD_NUMBER: _ClassVar[int]
LOOKUP_FIELD_NUMBER: _ClassVar[int]
MINTED_AT_FIELD_NUMBER: _ClassVar[int]
RECORD_FIELD_NUMBER: _ClassVar[int]
delegation_id: str
module: str
binding: str
audience: str
scopes: _containers.RepeatedScalarFieldContainer[str]
lookup: bool
minted_at: _timestamp_pb2.Timestamp
record: str
def __init__(self, delegation_id: _Optional[str] = ..., module: _Optional[str] = ..., binding: _Optional[str] = ..., audience: _Optional[str] = ..., scopes: _Optional[_Iterable[str]] = ..., lookup: bool = ..., minted_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., record: _Optional[str] = ...) -> None: ...

class SourceTriggerDeclaration(_message.Message):
__slots__ = ("release", "operation", "schedule", "identity", "digest", "last_occurrence_at", "next_occurrence_at", "undeclarable_reason")
RELEASE_FIELD_NUMBER: _ClassVar[int]
OPERATION_FIELD_NUMBER: _ClassVar[int]
SCHEDULE_FIELD_NUMBER: _ClassVar[int]
IDENTITY_FIELD_NUMBER: _ClassVar[int]
DIGEST_FIELD_NUMBER: _ClassVar[int]
LAST_OCCURRENCE_AT_FIELD_NUMBER: _ClassVar[int]
NEXT_OCCURRENCE_AT_FIELD_NUMBER: _ClassVar[int]
UNDECLARABLE_REASON_FIELD_NUMBER: _ClassVar[int]
release: str
operation: str
schedule: DeclaredTriggerSchedule
identity: TriggerIdentity
digest: str
last_occurrence_at: _timestamp_pb2.Timestamp
next_occurrence_at: _timestamp_pb2.Timestamp
undeclarable_reason: str
def __init__(self, release: _Optional[str] = ..., operation: _Optional[str] = ..., schedule: _Optional[_Union[DeclaredTriggerSchedule, _Mapping]] = ..., identity: _Optional[_Union[TriggerIdentity, _Mapping]] = ..., digest: _Optional[str] = ..., last_occurrence_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., next_occurrence_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., undeclarable_reason: _Optional[str] = ...) -> None: ...

class DelegateSourceTriggersRequest(_message.Message):
__slots__ = ("org_id", "source_id")
ORG_ID_FIELD_NUMBER: _ClassVar[int]
SOURCE_ID_FIELD_NUMBER: _ClassVar[int]
org_id: str
source_id: str
def __init__(self, org_id: _Optional[str] = ..., source_id: _Optional[str] = ...) -> None: ...

class DelegateSourceTriggersResponse(_message.Message):
__slots__ = ("delegations",)
DELEGATIONS_FIELD_NUMBER: _ClassVar[int]
delegations: _containers.RepeatedCompositeFieldContainer[TriggerDelegationReference]
def __init__(self, delegations: _Optional[_Iterable[_Union[TriggerDelegationReference, _Mapping]]] = ...) -> None: ...

class GetSourceTriggerDeclarationRequest(_message.Message):
__slots__ = ("org_id", "source_id")
ORG_ID_FIELD_NUMBER: _ClassVar[int]
SOURCE_ID_FIELD_NUMBER: _ClassVar[int]
org_id: str
source_id: str
def __init__(self, org_id: _Optional[str] = ..., source_id: _Optional[str] = ...) -> None: ...

class GetSourceTriggerDeclarationResponse(_message.Message):
__slots__ = ("declaration", "delegations", "children")
DECLARATION_FIELD_NUMBER: _ClassVar[int]
DELEGATIONS_FIELD_NUMBER: _ClassVar[int]
CHILDREN_FIELD_NUMBER: _ClassVar[int]
declaration: SourceTriggerDeclaration
delegations: _containers.RepeatedCompositeFieldContainer[TriggerDelegationReference]
children: _containers.RepeatedCompositeFieldContainer[TriggerChildContext]
def __init__(self, declaration: _Optional[_Union[SourceTriggerDeclaration, _Mapping]] = ..., delegations: _Optional[_Iterable[_Union[TriggerDelegationReference, _Mapping]]] = ..., children: _Optional[_Iterable[_Union[TriggerChildContext, _Mapping]]] = ...) -> None: ...

class ReconcileSourceRequest(_message.Message):
__slots__ = ("org_id", "source_id", "effect_id")
ORG_ID_FIELD_NUMBER: _ClassVar[int]
SOURCE_ID_FIELD_NUMBER: _ClassVar[int]
EFFECT_ID_FIELD_NUMBER: _ClassVar[int]
org_id: str
source_id: str
effect_id: str
def __init__(self, org_id: _Optional[str] = ..., source_id: _Optional[str] = ..., effect_id: _Optional[str] = ...) -> None: ...

class ReconcileSourceResponse(_message.Message):
__slots__ = ("effect_id", "status", "job_id")
EFFECT_ID_FIELD_NUMBER: _ClassVar[int]
STATUS_FIELD_NUMBER: _ClassVar[int]
JOB_ID_FIELD_NUMBER: _ClassVar[int]
effect_id: str
status: str
job_id: str
def __init__(self, effect_id: _Optional[str] = ..., status: _Optional[str] = ..., job_id: _Optional[str] = ...) -> None: ...

class LookupReconcileSourceRequest(_message.Message):
__slots__ = ("effect_id",)
EFFECT_ID_FIELD_NUMBER: _ClassVar[int]
effect_id: str
def __init__(self, effect_id: _Optional[str] = ...) -> None: ...

class SourceOperation(_message.Message):
__slots__ = ("name", "description", "method", "path", "query", "input_schema", "output_schema", "effect", "max_output_bytes", "digest")
class Effect(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
Expand Down
Loading